Jump to content

User:Rusalkii/historyColorCoder.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.
(function () {
    function getPageType() {
        if (typeof mw !== 'undefined' && mw.config) {
            const action = mw.config.get('wgAction');
            const special = mw.config.get('wgCanonicalSpecialPageName');
            if (special === 'Watchlist') return 'watchlist';
            if (action === 'history') return 'history';
            if (special === 'Contributions') return 'contributions';
            if (special === 'Recentchanges' || special === 'Recentchangeslinked') return 'recentchanges';
        }
        return 'other';
    }

    const pageType = getPageType();
    if (pageType === 'watchlist') return; // do nothing at all on Watchlist

    // Mark <body> so CSS can key off page type too, without guessing at
    // MediaWiki's own class names for these special pages.
    document.body.classList.add('hcc-active');
    if (pageType === 'recentchanges') document.body.classList.add('hcc-no-revert');

    const ROW_CONTAINER_SELECTOR = '#pagehistory, .mw-changeslist, .mw-contributions-list';
    const ROW_SELECTOR = '#pagehistory li, .mw-changeslist li, .mw-contributions-list li';

    const norm = s => s.trim().replace(/_/g, ' ').replace(/\s+/g, ' ').toLowerCase();
    const userOf = row => {
        const bdi = row.querySelector('.history-user a.mw-userlink bdi');
        return bdi ? norm(bdi.textContent) : null;
    };
    const deltaOf = row => {
        const el = row.querySelector('.mw-plusminus-pos, .mw-plusminus-neg, .mw-plusminus-null');
        if (!el) return null;
        const n = parseInt(el.textContent.replace(/−/g, '-').replace(/,/g, ''), 10);
        return Number.isNaN(n) ? null : n;
    };
    const sizeOf = row => {
        const el = row.querySelector('.history-size.mw-diff-bytes');
        if (!el) return null;
        const n = parseInt(el.getAttribute('data-mw-bytes'), 10);
        return Number.isNaN(n) ? null : n;
    };

    // Rows may be date-grouped, each date's edits sharing one <ul>, with
    // separate <ul>s (or other wrappers) per date sitting as siblings.
    // "Next row" therefore means: next <li> in the same group if one
    // exists, otherwise the first row in the next group.
    const nextRow = row => {
        let sib = row.nextElementSibling;
        while (sib && !sib.matches('li[data-mw-revid], li.mw-contributions-no-revision')) {
            sib = sib.nextElementSibling;
        }
        if (sib) return sib;

        let wrapper = row.parentElement;
        let wsib = wrapper?.nextElementSibling;
        while (wsib) {
            const inner = wsib.matches('li[data-mw-revid], li.mw-contributions-no-revision')
                ? wsib
                : wsib.querySelector('li[data-mw-revid], li.mw-contributions-no-revision');
            if (inner) return inner;
            wsib = wsib.nextElementSibling;
        }
        return null;
    };

    const markReverted = (row, test) => {
        let s = nextRow(row), n = 0;
        while (s && test(s)) { s.classList.add('detected-reverted'); s = nextRow(s); n++; }
        return n;
    };

    // Shared depth bound for both revert-chasing heuristics below, so a
    // pathological page can't make either one walk indefinitely.
    const MAX_REVERT_CHAIN_DEPTH = 15;

    const rollingByteMatch = (row, maxDepth = MAX_REVERT_CHAIN_DEPTH) => {
        const target = deltaOf(row);
        if (target === null) return false;
        let sum = 0, s = nextRow(row), author;
        const chain = [];
        for (let i = 0; s && i < maxDepth; i++, s = nextRow(s)) {
            const d = deltaOf(s);
            if (d === null) break;
            const u = userOf(s);
            if (author === undefined) author = u;
            else if (u !== author) return false;
            sum += d;
            chain.push(s);
            if (sum === -target) {
                chain.forEach(r => r.classList.add('detected-reverted'));
                return true;
            }
        }
        return false;
    };

    // --- Revert-detection building blocks ---
    // Every "this is a revert" synonym/shorthand lives here
    const REVERT_VERB = '(?:revert(?:ed|ing|s)?|rv|rvv|rvt|rollback(?:ed|ing)?|undid|undo(?:ne|ing)?)';
    const REVERT_VERB_RE = new RegExp(`\\b${REVERT_VERB}\\b`, 'i');
    const REVERT_BY_USER_RE = new RegExp(`\\b${REVERT_VERB}\\b.{0,40}?\\bby\\s+(\\S+)`, 'i');

    // Rows already absorbed into a resolved undo-chain (see resolveUndoChain
    // below), keyed by revid. Since rows are visited newest-first and an
    // undo chain's most recent link is always encountered before its older
    // links, the chain's head always resolves the whole chain first; this
    // set stops any interior link — which typically has its own "Undid
    // revision N by" comment — from independently re-walking and
    // re-resolving (potentially inconsistently) when it's reached later.
    const chainResolvedRevids = new Set();

    // If a row's comment is of the form "Undid revision N by X", returns N;
    // otherwise null. Chain-walking below only follows this specific,
    // revid-anchored phrasing, since it's the only revert form that names a
    // single, unambiguous target revision to build a chain from.
    const undidRevidOf = row => {
        const c = row.querySelector('.comment');
        if (!c) return null;
        const m = c.textContent.match(/\bundid revision (\d+) by/i);
        return m ? m[1] : null;
    };

    // Starting from a row whose comment says "Undid revision N by X" (with N
    // already known as firstTarget, so we don't re-derive it from the row's
    // own comment), walks the chain of undo-of-an-undo-of-an-undo... links
    // backward as far as it goes (bounded by maxDepth), then resolves the
    // whole chain by parity: an even-length chain fully cancels out — every
    // edit in it pairs with a neighbor and the net effect on the page is
    // zero — while an odd-length chain leaves the earliest edit (the
    // chain's root) as the one whose content change actually survives;
    // everything after it pairs up and cancels, but the root itself is left
    // unmarked.
    const resolveUndoChain = (row, firstTarget, maxDepth = MAX_REVERT_CHAIN_DEPTH) => {
        const chain = [row];
        let target = firstTarget;
        for (let i = 0; target && i < maxDepth; i++) {
            const t = revidToRow.get(target);
            if (!t) break; // target isn't rendered on this page load; stop here
            chain.push(t);
            target = undidRevidOf(t);
        }

        chain.forEach(r => { if (r.dataset.mwRevid) chainResolvedRevids.add(r.dataset.mwRevid); });

        // Only a whole number of pairs get marked; an odd leftover row (the
        // chain's root) stays unmarked since its change survives.
        const pairedCount = chain.length - (chain.length % 2);
        for (let i = 0; i < pairedCount; i += 2) {
            chain[i].classList.add('detected-revert');
            chain[i + 1].classList.add('detected-reverted');
        }
    };

    // --- Page status change detection ---
    const STATUS_LINK_SELECTOR =
        'a[href*="Wikipedia:Articles_for_deletion/"], ' +
        'a[href*="Wikipedia:Miscellany_for_deletion/"], ' +
        'a[href*="Wikipedia:Templates_for_discussion/"], ' +
        'a[href*="Wikipedia:Categories_for_discussion/"], ' +
        'a[href*="Wikipedia:Redirects_for_discussion/"], ' +
        'a[href*="Wikipedia:CSD"], ' +
        'a[href*="Wikipedia:PROD"], ' +
        'a[href*="Wikipedia:Proposed_deletion"], ' +
        'a[href*="Wikipedia:BLPPROD"], ' +
        'a[href*="Wikipedia:XFDC"], ' +
        'a[href*="User:Qwerfjkl/scripts/massXFD.js"], ' +
        'a[href*="Wikipedia:AFCSW"]';
    const STATUS_PHRASE_RE = /\b(?:Nominated for (?:deletion|merging|speedy deletion)|Requesting speedy deletion|Listed for discussion|Proposed for deletion|Proposing (?:article |page )?for deletion|Marking for deletion|closed as|Submitting using|Relisted on|discussion was relisted|Tagging page for)\b/i; /* Currently doesn't highlight e.g. RfD noms/relists/etc: Relisting "|Adding mass nomination at*/
    const CREATION_PHRASE_RE = /Created page with/i;
    const CREATING_PREFIX_RE = /^(?:←)?\s*Creating\s+/;
    const REDIRECT_PHRASE_RE = /\bredirect(?:ed)?\s+(?:page\s+)?to\b|\bretarget(?:ed|ting)?\s+(?:the\s+)?redirect\b|\bchanged redirect target\b/i;
    const REDIRECT_BYTE_THRESHOLD = 300;
    const PROTECTION_PHRASE_RE = /\b(?:Protected|Removed protection from|Changed protection level (?:for|of)|Changed protection settings for|Configured pending changes settings for|Reset pending changes settings for)\b/i;
    const MOVE_PHRASE_RE = /\bmoved page .+ to\b/i;
    const AWB_SCRIPT_PHRASE_RE = /\(via AWB script\)/i;

    const isCreatingVariant = (row, comment, text) => {
        if (!CREATING_PREFIX_RE.test(text)) return false;
        const firstLink = comment.querySelector('a');
        if (!firstLink) return false;
        const pageTitleEl = row.querySelector('.mw-changeslist-date');
        const pageTitle = pageTitleEl?.getAttribute('title');
        return !!pageTitle && firstLink.getAttribute('title') === pageTitle;
    };

    const processedRows = new WeakSet();

    // revid -> row lookup, populated as rows are discovered below, so
    // resolveUndoChain can jump straight to a chain link instead of
    // re-scanning the whole document for it on every hop.
    const revidToRow = new Map();

    const processRow = row => {
        if (row.dataset.mwRevid) revidToRow.set(row.dataset.mwRevid, row);

        if (processedRows.has(row)) return;
        processedRows.add(row);

        const comment = row.querySelector('.comment');
        if (!comment) return;
        const text = comment.textContent;

        // --- Reverting-edit detection: History and Contributions only ---
        if (pageType === 'history' || pageType === 'contributions') {
            const isRevert = REVERT_VERB_RE.test(text);

            if (isRevert) {
                let m, confirmed = false;

                // Revert-to-a-specific-revision forms: the target is a revision
                // id, not a username ("Undid revision N by X", "Reverted to
                // revision N by X", the rollback-tool shorthand "to rev. N by X").
                if ((m = text.match(/\bundid revision (\d+) by/i))) {
                    // Walk (and resolve) the whole chain of undo-of-undo-of-undo
                    // links this row is part of, unless an earlier, newer row
                    // in the same chain has already done so
                    if (!chainResolvedRevids.has(row.dataset.mwRevid)) {
                        resolveUndoChain(row, m[1]);
                    }
                    confirmed = true;

                } else if ((m = text.match(/\b(?:reverted to revision|to rev\.?)\s*(\d+)\s+by/i))) {
                    markReverted(row, s => s.dataset.mwRevid !== m[1]);
                    confirmed = true;

                } else {
                    // Revert-by-username forms: "Reverted edits by X", "rv edits by X", etc.
                    const byMatch = text.match(REVERT_BY_USER_RE);
                    if (byMatch) {
                        const u = norm(byMatch[1]
                            .replace(/^(?:user:|special:contributions\/)/i, '')
                            .replace(/[.,;:]+$/, ''));
                        confirmed = markReverted(row, s => userOf(s) === u) > 0;
                    }
                }

                if (!confirmed) {
                    confirmed = rollingByteMatch(row);
                }

                if (confirmed) {
                    row.classList.add('detected-revert');
                }
            }
        }

        // --- Page status change detection: all non-Watchlist pages ---
        const hasVenueLink = comment.querySelector(STATUS_LINK_SELECTOR);
        const isNomination = hasVenueLink && STATUS_PHRASE_RE.test(text);
        const isCreation = CREATION_PHRASE_RE.test(text) || isCreatingVariant(row, comment, text);
        const pageSize = sizeOf(row);
        const isRedirectish = REDIRECT_PHRASE_RE.test(text) && pageSize !== null && pageSize <= REDIRECT_BYTE_THRESHOLD;
        const isProtectionChange = PROTECTION_PHRASE_RE.test(text);
        const isMove = MOVE_PHRASE_RE.test(text);

        if (isNomination || isCreation || isRedirectish || isProtectionChange || isMove) {
            row.classList.add('detected-status-change');
        }

        if (AWB_SCRIPT_PHRASE_RE.test(text)) {
            row.classList.add('detected-bot-assisted');
        }
    };

    document.querySelectorAll(ROW_SELECTOR).forEach(processRow);

    const containers = document.querySelectorAll(ROW_CONTAINER_SELECTOR);
    if (containers.length) {
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;
                    if (node.matches('li')) {
                        processRow(node);
                    } else {
                        node.querySelectorAll('li').forEach(processRow);
                    }
                }
            }
        });
        containers.forEach(c => observer.observe(c, { childList: true, subtree: true }));
    }
})();