User:Rusalkii/historyColorCoder.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
This user script seems to have a documentation page at User:Rusalkii/historyColorCoder.
(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 }));
}
})();