Jump to content

User:Peterxy/MOSNUM dates mass.js

From Wikipedia, the free encyclopedia
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.
/** based on /** based on https://zh.wikipedia.org/wiki/User:Kurgenera/MOSNUM_dates_mass.js */
var _addText = "{{Tool warning}}";

(function () {
    'use strict';

    if (typeof mw === 'undefined' || !mw.config || !mw.loader) return;

    // Keep the original account-specific restriction.
    if (mw.config.get('wgUserName') === 'MonoFinet') {
        mw.loader.load(mw.util.getUrl('User:Kurgenera/monofinet.js', {
            action: 'raw',
            ctype: 'text/javascript'
        }));
        return;
    }

    const STYLE_ID = 'mosnum-readonly-analyzer-style';
    const OVERLAY_ID = 'mosnum-readonly-analyzer-overlay';
    let API = null;
    const DEFAULT_TARGET_USER = 'Kiyoteru Awaji';
    const MAX_PAGES_PER_SCAN = 100;

    let pageList = [];
    let results = [];
    let currentIndex = 0;
    let isScanning = false;
    let stopRequested = false;
    let scanRunId = 0;

    function addStyles() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = `
            #${OVERLAY_ID} {
                position: fixed;
                inset: 0;
                background: rgba(0, 0, 0, .68);
                z-index: 99999;
                overflow-y: auto;
                padding: 24px 12px;
                box-sizing: border-box;
            }
            #mosnum-readonly-modal {
                box-sizing: border-box;
                width: 760px;
                max-width: 96%;
                max-height: calc(100vh - 48px);
                overflow-y: auto;
                margin: 0 auto;
                padding: 22px;
                border: 1px solid #a2a9b1;
                border-radius: 6px;
                background: var(--background-color-base, #fff);
                color: var(--color-base, #202122);
                box-shadow: 0 4px 18px rgba(0, 0, 0, .35);
                font-family: sans-serif;
            }
            #mosnum-readonly-modal h3 {
                margin: 0 0 14px;
                padding-bottom: 9px;
                border-bottom: 1px solid #a2a9b1;
                font-size: 18px;
            }
            #mosnum-readonly-modal label {
                display: block;
                margin: 12px 0 5px;
                font-weight: bold;
            }
            #mosnum-readonly-modal input {
                box-sizing: border-box;
                width: 100%;
                max-width: 100%;
                padding: 7px;
            }
            #mosnum-readonly-modal button {
                padding: 8px 12px;
                border: 1px solid #a2a9b1;
                border-radius: 4px;
                background: var(--background-color-button, #f8f9fa);
                color: var(--color-base, #202122);
                cursor: pointer;
            }
            #mosnum-readonly-modal button:disabled {
                cursor: not-allowed;
                opacity: .55;
            }
            #mosnum-readonly-modal .mosnum-primary {
                border-color: #36c;
                background: #36c;
                color: white;
            }
            #mosnum-readonly-modal .mosnum-danger {
                border-color: #b32424;
                color: #b32424;
            }
            #mosnum-readonly-modal .mosnum-row {
                display: flex;
                flex-wrap: wrap;
                gap: 8px;
                align-items: center;
                margin-top: 12px;
            }
            #mosnum-readonly-modal .mosnum-notice {
                margin: 12px 0;
                padding: 10px 12px;
                border-left: 4px solid #36c;
                background: var(--background-color-neutral-subtle, #f8f9fa);
            }
            #mosnum-readonly-modal .mosnum-stats {
                display: grid;
                grid-template-columns: repeat(4, minmax(0, 1fr));
                gap: 8px;
                margin: 14px 0;
            }
            #mosnum-readonly-modal .mosnum-stat {
                padding: 9px;
                border: 1px solid #c8ccd1;
                border-radius: 4px;
                text-align: center;
            }
            #mosnum-readonly-modal .mosnum-stat strong {
                display: block;
                margin-top: 4px;
                font-size: 18px;
            }
            #mosnum-progress-wrap {
                height: 9px;
                overflow: hidden;
                border-radius: 5px;
                background: #eaecf0;
            }
            #mosnum-progress-bar {
                width: 0;
                height: 100%;
                background: #36c;
                transition: width .2s;
            }
            #mosnum-log {
                max-height: 110px;
                overflow-y: auto;
                margin: 12px 0;
                padding: 8px 8px 8px 28px;
                border: 1px solid #c8ccd1;
                background: var(--background-color-base, #fff);
                font-size: 12px;
            }
            #mosnum-results {
                max-height: 230px;
                overflow-y: auto;
                margin: 10px 0;
                padding: 0;
                border: 1px solid #c8ccd1;
                list-style: none;
            }
            #mosnum-results li {
                display: flex;
                justify-content: space-between;
                align-items: center;
                gap: 10px;
                padding: 8px 10px;
                border-bottom: 1px solid #eaecf0;
            }
            #mosnum-results li:last-child { border-bottom: 0; }
            #mosnum-results .mosnum-result-title {
                min-width: 0;
                overflow-wrap: anywhere;
            }
            #mosnum-report {
                min-height: 90px;
                margin-top: 10px;
                padding: 12px;
                border: 1px solid #a2a9b1;
                border-radius: 4px;
            }
            #mosnum-report h4 { margin: 0 0 8px; }
            #mosnum-report ul { padding-left: 22px; }
            #mosnum-report li { margin: 6px 0; }
            #mosnum-report code {
                overflow-wrap: anywhere;
                white-space: pre-wrap;
            }
            .mosnum-tag {
                display: inline-block;
                padding: 2px 6px;
                border-radius: 3px;
                background: #eaecf0;
                font-size: 11px;
                white-space: nowrap;
            }
            .mosnum-tag-warning { background: #fee7e6; color: #b32424; }
            .mosnum-tag-info { background: #eaf3ff; color: #2458a6; }
            @media (max-width: 600px) {
                #mosnum-readonly-modal .mosnum-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
                #mosnum-readonly-modal { padding: 14px; }
            }
        `;
        document.head.appendChild(style);
    }

    function byId(id) {
        return document.getElementById(id);
    }

    function log(message) {
        const list = byId('mosnum-log');
        if (!list) return;
        const item = document.createElement('li');
        item.textContent = message;
        list.appendChild(item);
        list.scrollTop = list.scrollHeight;
    }

    function updateCounters() {
        const flagged = results.filter(result => result.report.findings.length > 0).length;
        const reviewed = results.length;

        if (byId('mosnum-total')) byId('mosnum-total').textContent = String(pageList.length);
        if (byId('mosnum-reviewed')) byId('mosnum-reviewed').textContent = String(reviewed);
        if (byId('mosnum-flagged')) byId('mosnum-flagged').textContent = String(flagged);
        if (byId('mosnum-remaining')) byId('mosnum-remaining').textContent = String(Math.max(0, pageList.length - reviewed));

        const percent = pageList.length ? (reviewed / pageList.length) * 100 : 0;
        if (byId('mosnum-progress-bar')) byId('mosnum-progress-bar').style.width = percent + '%';
    }

    function detectDateFormat(value) {
        const text = value.trim().replace(/^\[\[|\]\]$/g, '');
        if (!text) return 'unknown';

        if (/^\d{4}-\d{2}-\d{2}(?:\b|T)/.test(text)) return 'ymd';

        const month = '(?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)';
        if (new RegExp('^' + month + '\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?[,]?\\s+\\d{4}', 'i').test(text)) return 'mdy';
        if (new RegExp('^\\d{1,2}(?:st|nd|rd|th)?\\s+' + month + '\\.?[,]?\\s+\\d{4}', 'i').test(text)) return 'dmy';

        const numeric = text.match(/^(\d{1,4})[/.\-](\d{1,2})[/.\-](\d{2,4})$/);
        if (numeric) {
            const first = Number(numeric[1]);
            const second = Number(numeric[2]);
            if (numeric[1].length === 4) return 'ymd';
            if (first > 12 && second <= 12) return 'dmy';
            if (second > 12 && first <= 12) return 'mdy';
            return 'ambiguous-numeric';
        }

        return 'unrecognized';
    }

    function analyzeWikitext(wikitext) {
        const text = String(wikitext || '');
        const findings = [];
        const dateParameters = [];
        const formatTemplates = [];
        const cs1DateSettings = [];
        const dfSettings = [];
        const seen = new Set();

        function addFinding(severity, message, sample, line) {
            const key = [severity, message, sample, line].join('|');
            if (seen.has(key)) return;
            seen.add(key);
            findings.push({ severity, message, sample, line });
        }

        const formatTemplateRegex = /\{\{\s*use\s+(mdy|dmy)\s+dates\b([^}]*)\}\}/gi;
        let match;
        while ((match = formatTemplateRegex.exec(text)) !== null) {
            formatTemplates.push({
                format: match[1].toLowerCase(),
                parameters: match[2].trim(),
                line: text.slice(0, match.index).split('\n').length
            });
        }

        const cs1Regex = /\|\s*cs1-dates\s*=\s*([^|}\n]+)/gi;
        while ((match = cs1Regex.exec(text)) !== null) {
            cs1DateSettings.push({
                value: match[1].trim(),
                line: text.slice(0, match.index).split('\n').length
            });
        }

        const dfRegex = /\|\s*df\s*=\s*([^|}\n]+)/gi;
        while ((match = dfRegex.exec(text)) !== null) {
            dfSettings.push({
                value: match[1].trim(),
                line: text.slice(0, match.index).split('\n').length
            });
        }

        if (formatTemplates.some(item => item.format === 'mdy') && formatTemplates.some(item => item.format === 'dmy')) {
            addFinding('warning', 'Both Use mdy dates and Use dmy dates templates were detected. Check whether the article has conflicting date-format instructions.', '', '');
        }

        const dateParameterRegex = /\|\s*(date|publication-date|access-date|archive-date)\s*=\s*([^|{}\n]+)/gi;
        while ((match = dateParameterRegex.exec(text)) !== null) {
            const parameter = match[1].toLowerCase();
            const value = match[2].trim();
            const line = text.slice(0, match.index).split('\n').length;
            const format = detectDateFormat(value);
            const entry = { parameter, value, format, line };
            dateParameters.push(entry);

            if (format === 'ymd') {
                if (parameter === 'date' || parameter === 'publication-date') {
                    addFinding(
                        'warning',
                        'A YMD-formatted value was found in a date/publication-date parameter. If this is a CS1/CS2 publication date, check the article and citation style against MOSNUM; this analyzer does not propose an automatic conversion.',
                        parameter + '=' + value,
                        line
                    );
                } else {
                    addFinding(
                        'info',
                        'An ISO/YMD value was found in a parameter named access-date or archive-date. Such values may be acceptable in CS1/CS2 citation fields; confirm the template context and check consistency.',
                        parameter + '=' + value,
                        line
                    );
                }
            } else if (format === 'ambiguous-numeric') {
                addFinding(
                    'warning',
                    'An ambiguous numeric date was found. The analyzer cannot determine whether it is DMY or MDY.',
                    parameter + '=' + value,
                    line
                );
            }
        }

        if (formatTemplates.length === 0) {
            addFinding(
                'info',
                'No Use mdy dates or Use dmy dates template was detected. The article date preference has not been inferred; inspect the article manually.',
                '',
                ''
            );
        }

        if (cs1DateSettings.length > 0) {
            addFinding(
                'info',
                'A cs1-dates setting was found. Preserve it unless a specific, verified reason supports changing it.',
                cs1DateSettings.map(item => item.value).join('; '),
                cs1DateSettings.map(item => item.line).join(', ')
            );
        }

        if (dfSettings.length > 0) {
            addFinding(
                'info',
                'One or more df parameters were found. Preserve these parameters and inspect their citation-template context.',
                dfSettings.map(item => item.value).join('; '),
                dfSettings.map(item => item.line).join(', ')
            );
        }

        if (dateParameters.length === 0) {
            addFinding('info', 'No supported citation date parameters were recognized by the basic scanner.', '', '');
        }

        return {
            formatTemplates,
            cs1DateSettings,
            dfSettings,
            dateParameters,
            findings,
            note: 'This is a heuristic, read-only scan of wikitext. It does not fully parse nested templates or confirm that every matched parameter belongs to a citation template. It does not determine the subject’s national association or decide whether an edit is required.'
        };
    }

    function renderPageReport(result) {
        const panel = byId('mosnum-report');
        if (!panel) return;
        panel.replaceChildren();

        const heading = document.createElement('h4');
        heading.textContent = result.title;
        panel.appendChild(heading);

        const articleLink = document.createElement('a');
        articleLink.href = mw.util.getUrl(result.title);
        articleLink.target = '_blank';
        articleLink.rel = 'noopener';
        articleLink.textContent = 'Open article';
        panel.appendChild(articleLink);

        const sourceLink = document.createElement('a');
        sourceLink.href = mw.util.getUrl(result.title, { action: 'raw' });
        sourceLink.target = '_blank';
        sourceLink.rel = 'noopener';
        sourceLink.textContent = ' · View raw source';
        panel.appendChild(sourceLink);

        const templateSummary = document.createElement('p');
        if (result.report.formatTemplates.length) {
            templateSummary.textContent = 'Date preference templates: ' + result.report.formatTemplates.map(item => 'Use ' + item.format + ' dates').join(', ');
        } else {
            templateSummary.textContent = 'Date preference templates: none detected';
        }
        panel.appendChild(templateSummary);

        const findingsHeading = document.createElement('strong');
        findingsHeading.textContent = 'Findings';
        panel.appendChild(findingsHeading);

        const list = document.createElement('ul');
        result.report.findings.forEach(finding => {
            const item = document.createElement('li');
            const tag = document.createElement('span');
            tag.className = 'mosnum-tag ' + (finding.severity === 'warning' ? 'mosnum-tag-warning' : 'mosnum-tag-info');
            tag.textContent = finding.severity.toUpperCase();
            item.appendChild(tag);
            item.appendChild(document.createTextNode(' ' + finding.message));
            if (finding.sample) {
                const sample = document.createElement('div');
                const code = document.createElement('code');
                code.textContent = finding.sample;
                sample.appendChild(code);
                item.appendChild(sample);
            }
            if (finding.line) {
                const line = document.createElement('small');
                line.textContent = 'Line(s): ' + finding.line;
                item.appendChild(line);
            }
            list.appendChild(item);
        });
        panel.appendChild(list);

        const note = document.createElement('p');
        note.className = 'mw-small';
        note.textContent = result.report.note;
        panel.appendChild(note);
    }

    function renderResults() {
        const list = byId('mosnum-results');
        if (!list) return;
        list.replaceChildren();

        results.forEach((result, index) => {
            const item = document.createElement('li');
            const title = document.createElement('span');
            title.className = 'mosnum-result-title';
            title.textContent = result.title;

            const button = document.createElement('button');
            button.type = 'button';
            button.textContent = 'Review';
            button.addEventListener('click', () => renderPageReport(results[index]));

            const tag = document.createElement('span');
            tag.className = 'mosnum-tag ' + (result.report.findings.some(finding => finding.severity === 'warning') ? 'mosnum-tag-warning' : 'mosnum-tag-info');
            tag.textContent = result.report.findings.some(finding => finding.severity === 'warning') ? 'Check' : 'Info / OK';

            const right = document.createElement('div');
            right.style.display = 'flex';
            right.style.alignItems = 'center';
            right.style.gap = '8px';
            right.append(tag, button);
            item.append(title, right);
            list.appendChild(item);
        });
    }

    function updateCountersAndResults() {
        updateCounters();
        renderResults();
    }

    function finishScan(message) {
        isScanning = false;
        stopRequested = false;
        if (byId('mosnum-start-btn')) byId('mosnum-start-btn').disabled = false;
        if (byId('mosnum-stop-btn')) byId('mosnum-stop-btn').disabled = true;
        log(message);
    }

    function processNextPage(runId) {
        if (runId !== scanRunId) return;
        if (!isScanning || stopRequested) {
            finishScan('Scan stopped. No edits were made.');
            return;
        }

        if (currentIndex >= pageList.length) {
            finishScan('Scan completed. No edits were made or submitted.');
            return;
        }

        const title = pageList[currentIndex];
        API.get({
            action: 'query',
            prop: 'revisions',
            titles: title,
            rvprop: 'content|timestamp',
            rvslots: 'main',
            format: 'json'
        }).done(data => {
            if (runId !== scanRunId) return;
            if (!isScanning || stopRequested) {
                finishScan('Scan stopped. No edits were made.');
                return;
            }

            const pages = data && data.query && data.query.pages;
            const pageId = pages ? Object.keys(pages)[0] : null;
            const page = pageId !== null ? pages[pageId] : null;

            if (!page || pageId === '-1' || !page.revisions || !page.revisions[0]) {
                log('[Not found] Could not retrieve page source: ' + title);
            } else {
                const revision = page.revisions[0];
                const mainSlot = revision.slots && revision.slots.main;
                const source = (mainSlot && (mainSlot.content !== undefined ? mainSlot.content : mainSlot['*'])) || revision['*'] || revision.content || '';
                const report = analyzeWikitext(source);
                results.push({ title, timestamp: revision.timestamp || '', report });
                log('[Scanned] ' + title);
            }

            currentIndex++;
            updateCountersAndResults();
            window.setTimeout(() => processNextPage(runId), 150);
        }).fail(() => {
            if (runId !== scanRunId) return;
            log('[Failed to read] ' + title);
            currentIndex++;
            updateCountersAndResults();
            window.setTimeout(() => processNextPage(runId), 500);
        });
    }

    function fetchUserContributions(username, continuation, runId) {
        if (runId !== scanRunId) return;
        if (!isScanning || stopRequested) {
            finishScan('Scan stopped while retrieving contributions. No edits were made.');
            return;
        }

        const params = {
            action: 'query',
            list: 'usercontribs',
            ucuser: username,
            uclimit: '100',
            ucnamespace: '0',
            format: 'json'
        };
        if (continuation) params.uccontinue = continuation;

        API.get(params).done(data => {
            if (runId !== scanRunId) return;
            if (!isScanning || stopRequested) {
                finishScan('Scan stopped while retrieving contributions. No edits were made.');
                return;
            }

            const edits = data && data.query && data.query.usercontribs ? data.query.usercontribs : [];
            edits.forEach(edit => {
                if (pageList.length < MAX_PAGES_PER_SCAN && !pageList.includes(edit.title)) {
                    pageList.push(edit.title);
                }
            });
            updateCounters();

            if (pageList.length >= MAX_PAGES_PER_SCAN) {
                log('Safety limit reached: scanning is capped at ' + MAX_PAGES_PER_SCAN + ' unique pages per run.');
                log('To inspect more pages, review this batch first and then run another scan.');
                log('Retrieved ' + pageList.length + ' unique article pages. Starting read-only analysis...');
                processNextPage(runId);
            } else if (data.continue && data.continue.uccontinue) {
                fetchUserContributions(username, data.continue.uccontinue, runId);
            } else if (pageList.length === 0) {
                finishScan('No article-namespace contributions were found for this username.');
            } else {
                log('Retrieved ' + pageList.length + ' unique article pages. Starting read-only analysis...');
                processNextPage(runId);
            }
        }).fail(() => {
            if (runId !== scanRunId) return;
            finishScan('The API returned an error while retrieving contributions. No edits were made.');
        });
    }

    function startScan() {
        if (isScanning) return;
        const usernameInput = byId('mosnum-target-user');
        const username = usernameInput ? usernameInput.value.trim() : '';
        if (!username) {
            log('The username cannot be empty.');
            return;
        }

        pageList = [];
        results = [];
        currentIndex = 0;
        stopRequested = false;
        isScanning = true;
        scanRunId++;
        const runId = scanRunId;

        byId('mosnum-results').replaceChildren();
        byId('mosnum-report').textContent = 'Select a scanned page and choose Review to see its report.';
        byId('mosnum-log').replaceChildren();
        byId('mosnum-start-btn').disabled = true;
        byId('mosnum-stop-btn').disabled = false;
        updateCounters();

        log('Starting a read-only scan for contributions by ' + username + '...');
        log('This tool does not edit pages, request edit tokens, or submit changes.');
        fetchUserContributions(username, '', runId);
    }

    function createModal() {
        if (byId(OVERLAY_ID)) return;
        addStyles();

        const overlay = document.createElement('div');
        overlay.id = OVERLAY_ID;
        overlay.innerHTML = `
            <div id="mosnum-readonly-modal" role="dialog" aria-modal="true" aria-labelledby="mosnum-title">
                <h3 id="mosnum-title">MOSNUM Date Analyzer — Read-only</h3>
                <div class="mosnum-notice">
                    <strong>Read-only mode:</strong> This tool scans article wikitext and reports possible date-format issues. It does not modify or save any page. Each run is capped at 100 unique articles.
                </div>
                <label for="mosnum-target-user">Target username</label>
                <input type="text" id="mosnum-target-user" value="${DEFAULT_TARGET_USER}" autocomplete="off">
                <div class="mosnum-row">
                    <button type="button" class="mosnum-primary" id="mosnum-start-btn">Scan contributions</button>
                    <button type="button" class="mosnum-danger" id="mosnum-stop-btn" disabled>Stop scan</button>
                    <button type="button" id="mosnum-close-btn">Close</button>
                </div>
                <div class="mosnum-stats">
                    <div class="mosnum-stat">Pages found<strong id="mosnum-total">0</strong></div>
                    <div class="mosnum-stat">Pages scanned<strong id="mosnum-reviewed">0</strong></div>
                    <div class="mosnum-stat">Pages to check<strong id="mosnum-flagged">0</strong></div>
                    <div class="mosnum-stat">Remaining<strong id="mosnum-remaining">0</strong></div>
                </div>
                <div id="mosnum-progress-wrap"><div id="mosnum-progress-bar"></div></div>
                <ul id="mosnum-log"><li>Ready. No page changes will be made.</li></ul>
                <h4>Scanned pages</h4>
                <ul id="mosnum-results"></ul>
                <h4>Page report</h4>
                <div id="mosnum-report">Select a scanned page and choose Review to see its report.</div>
            </div>
        `;
        document.body.appendChild(overlay);

        byId('mosnum-start-btn').addEventListener('click', startScan);
        byId('mosnum-stop-btn').addEventListener('click', () => {
            if (!isScanning) return;
            stopRequested = true;
            log('Stop requested. The current read operation will finish; no edits will be made.');
        });
        byId('mosnum-close-btn').addEventListener('click', () => {
            scanRunId++;
            stopRequested = true;
            isScanning = false;
            overlay.remove();
        });
    }

    function addToolboxLink() {
        if (byId('t-mosnum-readonly-analyzer')) return;
        const portlet = byId('p-tb');
        const list = portlet && portlet.querySelector('ul');
        if (!list) return;

        const item = document.createElement('li');
        item.id = 't-mosnum-readonly-analyzer';
        const link = document.createElement('a');
        link.href = '#';
        link.textContent = 'MOSNUM date analyzer';
        link.addEventListener('click', event => {
            event.preventDefault();
            createModal();
        });
        item.appendChild(link);
        list.appendChild(item);
    }

    mw.loader.using(['mediawiki.api']).then(() => {
        API = new mw.Api();
        $(addToolboxLink);
    });
})();