User:Daniel Quinlan/Scripts/Unfiltered.js
'use strict';
const usageCounters = {};
function incrementCounter(key) {
usageCounters[key] = (usageCounters[key] || 0) + 1;
}
function saveCounters() {
const storageKey = 'fh-counters';
const existingString = localStorage.getItem(storageKey);
if (!existingString) return;
const existing = existingString ? JSON.parse(existingString) : {};
for (const [key, count] of Object.entries(usageCounters)) {
existing[key] = (existing[key] || 0) + count;
}
localStorage.setItem(storageKey, JSON.stringify(existing));
}
class Mutex {
constructor() {
this.lock = Promise.resolve();
}
run(fn) {
const p = this.lock.then(fn, fn);
this.lock = p.finally(() => {});
return p;
}
}
class RevisionData {
constructor(api, special) {
this.api = api;
this.special = special;
this.elements = {};
this.deletedElements = {};
this.firstRevid = null;
this.lastRevid = null;
this.nextRevid = null;
const pager = document.querySelector('.mw-pager-navigation-bar');
this.hasOlder = !!pager?.querySelector('a.mw-lastlink');
this.hasNewer = !!pager?.querySelector('a.mw-firstlink');
const listItems = document.querySelectorAll('ul.mw-contributions-list > li[data-mw-revid]');
this.timestamps = {};
for (const li of listItems) {
const revid = Number(li.getAttribute('data-mw-revid'));
if (!revid) continue;
this.elements[revid] = li;
if (!this.firstRevid) {
this.firstRevid = revid;
}
this.lastRevid = revid;
if (special === 'DeletedContributions') {
this.timestamps[revid] = this.extractDeletedTimestamp(li);
} else {
this.timestamps[revid] = null;
}
}
this.timestampsPromise = this.fetchTimestamps();
}
extractDeletedTimestamp(li) {
const link = li?.querySelector('a.mw-changeslist-date');
const match = link?.href?.match(/[&?]timestamp=(\d{14})\b/);
if (!match) return null;
const t = match[1];
return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}T${t.slice(8, 10)}:${t.slice(10, 12)}:${t.slice(12, 14)}Z`;
}
async fetchTimestamps() {
incrementCounter('timestamps-total');
const missing = Object.entries(this.timestamps)
.filter(([, ts]) => ts === null)
.map(([revid]) => revid)
.sort((a, b) => a - b);
if (!missing.length) return;
incrementCounter('timestamps-missing');
const highest = missing.pop();
missing.unshift(highest);
for (let i = 0; i < missing.length; i += 50) {
const chunk = missing.slice(i, i + 50);
incrementCounter('timestamps-query');
const data = await this.api.get({
action: 'query',
prop: 'revisions',
revids: chunk.join('|'),
rvprop: 'ids|timestamp',
format: 'json'
});
const pages = data?.query?.pages || {};
for (const page of Object.values(pages)) {
for (const rev of page.revisions || []) {
this.timestamps[rev.revid] = rev.timestamp;
}
}
}
}
async fetchNextRevid(caller) {
incrementCounter(`next-revid-total-${caller}`)
if (!this.lastRevid || !this.hasOlder) return;
const link = document.querySelector('a.mw-nextlink');
const match = link?.href?.match(/[?&]offset=(\d{14})\b/);
if (!match) return;
const offset = match[1];
const params = {
action: 'query',
list: 'usercontribs',
ucstart: offset,
uclimit: 50,
ucdir: 'older',
ucprop: 'ids|timestamp',
format: 'json',
};
const user = mw.config.get('wgRelevantUserName');
if (user) {
params.ucuser = user;
} else {
const userToolsBDI = document.querySelector('.mw-contributions-user-tools bdi');
const userName = userToolsBDI ? userToolsBDI.textContent.trim() : '';
if (!userName) return;
params.uciprange = userName;
}
incrementCounter(`next-revid-query-${caller}`)
const data = await this.api.get(params);
const contribs = data?.query?.usercontribs;
if (!contribs?.length) return;
const next = contribs
.filter(c => c.revid < this.lastRevid)
.sort((a, b) => b.revid - a.revid)[0]
if (!next) return;
this.nextRevid = next.revid ?? null;
if (next.timestamp) {
this.timestamps[next.revid] = next.timestamp;
}
}
async getTimestamp(revid) {
const ts = this.timestamps[revid];
if (ts !== null) return ts;
await this.timestampsPromise;
return this.timestamps[revid];
}
async getNextRevid(caller) {
if (this.nextRevid !== null) {
return this.nextRevid;
}
if (!this.nextRevidPromise) {
this.nextRevidPromise = this.fetchNextRevid(caller);
}
await this.nextRevidPromise;
return this.nextRevid;
}
}
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.DateFormatter']).then(async () => {
const special = mw.config.get('wgCanonicalSpecialPageName');
if (!['Contributions', 'DeletedContributions'].includes(special)) return;
const formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
const relevantUser = mw.config.get('wgRelevantUserName');
const isSysop = mw.config.get('wgUserGroups').includes('sysop');
const api = new mw.Api();
const mutex = new Mutex();
const revisionData = new RevisionData(api, special);
let showUser;
let toggleButtonDisplayed = false;
incrementCounter('script-run');
addFilterLogCSS();
addToggleButton();
if (relevantUser) {
if (!ensureContributionsList(revisionData)) return;
incrementCounter('mode-single');
showUser = false;
await processUser(relevantUser);
} else {
incrementCounter('mode-multiple');
showUser = true;
for (const user of getUsersFromContributionsList()) {
incrementCounter('mode-multiple-user');
await processUser(user);
}
}
saveCounters();
function addFilterLogCSS() {
mw.util.addCSS(`
.abusefilter-container {
display: inline-block;
margin-left: 0.5em;
}
.abusefilter-container::before {
content: "[";
}
.abusefilter-container::after {
content: "]";
}
a.abusefilter-logid {
display: inline-block;
margin: 0 2px;
}
a.abusefilter-logid-tag {
color: var(--color-content-added, #348469);
}
a.abusefilter-logid-showcaptcha {
color: var(--color-content-removed, #d0450b);
}
a.abusefilter-logid-warn {
color: var(--color-warning, #957013);
}
a.abusefilter-logid-disallow {
color: var(--color-error, #e90e01);
}
li.mw-contributions-deleted, li.mw-contributions-no-revision, li.mw-contributions-removed {
background-color: color-mix(in srgb, var(--background-color-destructive, #bf3c2c) 16%, transparent);
margin-bottom: 0;
padding-bottom: 0.1em;
}
.mw-pager-body.hide-unfiltered li.mw-contributions-deleted,
.mw-pager-body.hide-unfiltered li.mw-contributions-no-revision,
.mw-pager-body.hide-unfiltered li.mw-contributions-removed {
display: none;
}
`);
}
function addToggleButton() {
const expandIcon = '';
const collapseIcon = '';
const form = document.querySelector('.mw-htmlform');
if (!form) return;
const legend = form.querySelector('legend');
if (!legend) return;
const pager = document.querySelector('.mw-pager-body');
if (!pager) return;
legend.style.display = 'flex';
const button = document.createElement('button');
button.type = 'button';
button.className = 'unfiltered-toggle-button';
button.title = 'Collapse unfiltered';
button.innerHTML = collapseIcon;
button.style.cssText = `
background: none;
border: none;
cursor: pointer;
width: 24px;
height: 24px;
padding: 0;
margin-left: auto;
vertical-align: middle;
display: none;
`;
button.addEventListener('click', e => {
e.stopPropagation();
const hideUnfiltered = pager.classList.toggle('hide-unfiltered');
button.innerHTML = hideUnfiltered ? expandIcon : collapseIcon;
button.title = hideUnfiltered ? 'Expand unfiltered' : 'Collapse unfiltered';
});
legend.appendChild(button);
}
function ensureContributionsList(revisionData) {
if (!revisionData.lastRevid) {
const pagerBody = document.querySelector('.mw-pager-body');
if (pagerBody && !pagerBody.querySelector('.mw-contributions-list')) {
const ul = document.createElement('ul');
ul.className = 'mw-contributions-list';
pagerBody.appendChild(ul);
} else {
return false;
}
}
return true;
}
function getUsersFromContributionsList() {
const links = document.querySelectorAll('ul.mw-contributions-list li a.mw-anonuserlink');
const users = new Set();
for (const link of links) {
users.add(link.textContent.trim());
}
return Array.from(users);
}
async function processUser(user) {
let start = await getStartValue(revisionData);
const abuseLogPromise = fetchAbuseLog(user, start);
const deletedRevisionsPromise = isSysop
? fetchDeletedRevisions(user, start)
: Promise.resolve();
const remainingHits = await abuseLogPromise;
await deletedRevisionsPromise;
updateRevisions(remainingHits);
const removed = Object.values(remainingHits).flatMap(entries =>
entries.map(entry => ({ ...entry, revtype: 'removed' }))
);
for (const entry of removed) {
addEntry(entry);
}
}
async function fetchAbuseLog(user, start) {
const limit = isSysop ? 250 : 50;
const revisionMap = new Map();
const params = {
action: 'query',
list: 'abuselog',
afllimit: limit,
aflprop: 'ids|filter|user|title|action|result|timestamp|hidden|revid',
afluser: user,
format: 'json',
};
const hits = {};
let excessEntryCount = 0;
do {
incrementCounter('abuselog-query');
const data = await api.get({ ...params, ...(start && { aflstart: start })});
const logs = data?.query?.abuselog || [];
const unmatched = [];
start = data?.continue?.aflstart || null;
for (const entry of logs) {
const revid = entry.revid;
if (!revisionData.lastRevid || revid > revisionData.lastRevid) {
excessEntryCount++;
} else {
const lastTimestamp = await revisionData.getTimestamp(revisionData.lastRevid);
if (entry.timestamp < lastTimestamp) {
excessEntryCount++;
}
}
entry.filter_id = entry.filter_id || 'private';
const actionAttemptKey = `${entry.filter_id}|${entry.filter}|${entry.title}|${entry.user}`;
if (revid) {
revisionMap.set(actionAttemptKey, revid);
}
const resolvedRevid = revid || revisionMap.get(actionAttemptKey);
entry.result = entry.result || 'none';
entry.userstring = user;
if (resolvedRevid) {
entry.revtype = revid ? 'matched' : 'inferred';
hits[resolvedRevid] ??= [];
hits[resolvedRevid].push(entry);
} else if (special === 'Contributions') {
entry.revtype = 'no-revision';
addEntry(entry);
}
}
if (excessEntryCount >= limit) {
start = null;
}
updateRevisions(hits);
} while (start);
return hits;
}
async function fetchDeletedRevisions(user, start) {
const deletedRevs = [];
let adrcontinue = null;
do {
const params = {
action: 'query',
list: 'alldeletedrevisions',
adruser: user,
adrprop: 'flags|ids|parsedcomment|size|tags|timestamp|user',
adrlimit: 50,
format: 'json',
};
if (adrcontinue) {
params.adrcontinue = adrcontinue;
}
incrementCounter('deleted-query');
const data = await api.get({ ...params, ...(start && { adrstart: start })});
for (const page of data?.query?.alldeletedrevisions || []) {
for (const entry of page.revisions || []) {
const tooNew = revisionData.hasNewer && revisionData.firstRevid && entry.revid > revisionData.firstRevid;
let tooOld;
if (!tooNew && revisionData.hasOlder) {
const nextRevid = await revisionData.getNextRevid('deleted');
tooOld = nextRevid && entry.revid < nextRevid;
} else {
tooOld = false;
}
if (!tooNew && !tooOld) {
entry.title = page.title;
entry.userstring = user;
entry.revtype = 'deleted';
addEntry(entry);
}
if (tooOld) return deletedRevs;
}
}
adrcontinue = data?.continue?.adrcontinue || null;
} while (adrcontinue);
}
async function getStartValue(revisionData) {
if (!revisionData.hasOlder) {
return null;
}
const urlParams = new URLSearchParams(location.search);
const dirParam = urlParams.get('dir');
const offsetParam = urlParams.get('offset');
if (dirParam !== 'prev' && /^\d{14}$/.test(offsetParam)) {
return offsetParam;
} else if (dirParam === 'prev') {
const iso = await revisionData.getTimestamp(revisionData.firstRevid);
if (iso) {
const date = new Date(iso);
date.setUTCSeconds(date.getUTCSeconds() + 1);
return date.toISOString().replace(/\D/g, '').slice(0, 14);
}
}
return null;
}
function updateRevisions(hits) {
const matched = [];
for (const revid in hits) {
const li = revisionData.elements[revid] || revisionData.deletedElements[revid];
if (!li) continue;
let container = li.querySelector('.abusefilter-container');
if (!container) {
container = document.createElement('span');
container.className = 'abusefilter-container';
li.appendChild(container);
}
for (const entry of hits[revid]) {
container.insertBefore(createFilterElement(entry), container.firstChild);
}
matched.push(revid);
}
for (const revid of matched) {
delete hits[revid];
}
}
async function addEntry(entry) {
await mutex.run(() => addEntryUnsafe(entry));
}
async function addEntryUnsafe(entry) {
function insertFilterElement(existingLi, entry) {
const container = existingLi.querySelector('.abusefilter-container');
if (container) {
container.insertBefore(createFilterElement(entry), container.firstChild);
}
}
function insertFilterItem(existingLi, entry) {
const li = createFilterItem(entry);
existingLi.parentElement.insertBefore(li, existingLi);
if (entry.revtype === 'deleted') {
revisionData.deletedElements[entry.revid] = li;
}
}
async function shouldAddUnresolved(entry, revisionData) {
if (!revisionData.hasOlder) return true;
const nextRevid = await revisionData.getNextRevid('unresolved');
if (!nextRevid) return true;
const nextTimestamp = await revisionData.getTimestamp(nextRevid);
return !nextTimestamp || entry.timestamp > nextTimestamp;
}
const allLis = Array.from(document.querySelectorAll('ul.mw-contributions-list > li'));
const firstLi = allLis[0];
for (const existingLi of allLis) {
const revid = existingLi.getAttribute('data-mw-revid') || existingLi.getAttribute('data-revid');
if (entry.revid && revid && entry.revid > revid) {
if (!(existingLi === firstLi && revisionData.hasNewer)) {
insertFilterItem(existingLi, entry);
}
return;
} else if (entry.revid && revid && entry.revid == revid) {
insertFilterElement(existingLi, entry);
return;
} else {
const dataTimestamp = existingLi.getAttribute('data-timestamp');
const ts = dataTimestamp ?? (revid ? await revisionData.getTimestamp(revid) : null);
if (!ts) return;
if (entry.timestamp > ts) {
if (!(existingLi === firstLi && revisionData.hasNewer)) {
insertFilterItem(existingLi, entry);
}
return;
} else if (
existingLi.getAttribute('data-timestamp') === entry.timestamp &&
existingLi.getAttribute('data-title') === entry.title &&
existingLi.getAttribute('data-user') === entry.user &&
['mw-contributions-no-revision', 'mw-contributions-removed']
.some(c => existingLi.classList.contains(c))
) {
insertFilterElement(existingLi, entry);
return;
}
}
}
if (await shouldAddUnresolved(entry, revisionData)) {
const lastUl = document.querySelectorAll('ul.mw-contributions-list');
if (lastUl.length) {
const li = createFilterItem(entry);
lastUl[lastUl.length - 1].appendChild(li);
if (entry.revtype === 'deleted') {
revisionData.deletedElements[entry.revid] = li;
}
}
}
}
function createFilterElement(entry) {
const isPrivate = entry.filter_id === 'private';
const element = document.createElement(isPrivate ? 'span' : 'a');
if (!isPrivate) {
element.href = `/wiki/Special:AbuseLog/${entry.id}`;
}
element.className = `abusefilter-logid abusefilter-logid-${entry.result}`;
element.textContent = isPrivate ? entry.filter : entry.filter_id;
element.title = entry.filter;
return element;
}
function createFilterItem(entry) {
function formatRevtype(revtype) {
return revtype
.replace(/-/g, ' ')
.replace(/^\w/, c => c.toUpperCase());
}
if (!toggleButtonDisplayed) {
const button = document.querySelector('.unfiltered-toggle-button');
if (button) {
toggleButtonDisplayed = true;
button.style.display = '';
}
}
const li = document.createElement('li');
li.className = `mw-contributions-${entry.revtype}`;
if (entry.revid) {
li.setAttribute('data-revid', entry.revid);
}
li.setAttribute('data-timestamp', entry.timestamp);
li.setAttribute('data-title', entry.title);
li.setAttribute('data-user', entry.user);
const formattedTimestamp = formatTimeAndDate(new Date(entry.timestamp));
let timestamp;
if (entry.revtype === 'deleted') {
const ts = new Date(entry.timestamp).toISOString().replace(/\D/g, '').slice(0, 14);
timestamp = document.createElement('a');
timestamp.className = 'mw-changeslist-date';
timestamp.href = `/w/index.php?title=Special:Undelete&target=${encodeURIComponent(entry.title)}×tamp=${ts}`;
timestamp.title = 'Special:Undelete';
timestamp.textContent = formattedTimestamp;
} else {
timestamp = document.createElement('span');
timestamp.className = 'mw-changeslist-date';
timestamp.textContent = formattedTimestamp;
}
const titleSpanWrapper = document.createElement('span');
titleSpanWrapper.className = 'mw-title';
const titleBdi = document.createElement('bdi');
titleBdi.setAttribute('dir', 'ltr');
const titleLink = document.createElement('a');
titleLink.textContent = entry.title;
const pageTitleEncoded = encodeURIComponent(entry.title.replace(/ /g, '_'));
if (entry.revtype === 'deleted') {
titleLink.href = `/w/index.php?title=${pageTitleEncoded}&action=edit&redlink=1`;
titleLink.className = 'mw-contributions-title new';
titleLink.title = '';
} else {
titleLink.href = `/wiki/${pageTitleEncoded}`;
titleLink.className = 'mw-contributions-title';
titleLink.title = entry.title;
}
titleBdi.appendChild(titleLink);
titleSpanWrapper.appendChild(titleBdi);
let afContainer;
if (entry.revtype !== 'deleted') {
afContainer = document.createElement('span');
afContainer.className = 'abusefilter-container';
const afElement = createFilterElement(entry);
afContainer.appendChild(afElement);
}
li.appendChild(timestamp);
li.appendChild(document.createTextNode(' '));
const sep1 = document.createElement('span');
sep1.className = 'mw-changeslist-separator';
li.appendChild(sep1);
li.appendChild(document.createTextNode(' '));
const label = document.createElement('span');
label.textContent = formatRevtype(entry.revtype);
label.style.fontStyle = 'italic';
li.appendChild(label);
li.appendChild(document.createTextNode(' '));
const sep2 = document.createElement('span');
sep2.className = 'mw-changeslist-separator';
li.appendChild(sep2);
li.appendChild(document.createTextNode(' '));
if (entry.revtype === 'deleted') {
if (entry.minor !== undefined) {
const minorAbbr = document.createElement('abbr');
minorAbbr.className = 'minoredit';
minorAbbr.title = 'This is a minor edit';
minorAbbr.textContent = 'm';
li.appendChild(document.createTextNode(' '));
li.appendChild(minorAbbr);
li.appendChild(document.createTextNode(' '));
}
if (entry.parentid === 0) {
const newAbbr = document.createElement('abbr');
newAbbr.className = 'newpage';
newAbbr.title = 'This edit created a new page';
newAbbr.textContent = 'N';
li.appendChild(document.createTextNode(' '));
li.appendChild(newAbbr);
li.appendChild(document.createTextNode(' '));
}
}
li.appendChild(titleSpanWrapper);
if (showUser && entry.user) {
li.appendChild(document.createTextNode(' '));
const sep3 = document.createElement('span');
sep3.className = 'mw-changeslist-separator';
li.appendChild(sep3);
li.appendChild(document.createTextNode(' '));
const userBdi = document.createElement('bdi');
userBdi.setAttribute('dir', 'ltr');
userBdi.className = 'mw-userlink mw-anonuserlink';
const userLink = document.createElement('a');
userLink.href = `/wiki/Special:Contributions/${encodeURIComponent(entry.user)}`;
userLink.className = 'mw-userlink mw-anonuserlink';
userLink.title = `Special:Contributions/${entry.user}`;
userLink.textContent = entry.userstring || entry.user;
userBdi.appendChild(userLink);
li.appendChild(userBdi);
}
if (entry.revtype === 'deleted' && entry.parsedcomment) {
const commentSpan = document.createElement('span');
commentSpan.className = 'comment';
commentSpan.innerHTML = `(${entry.parsedcomment})`;
li.appendChild(document.createTextNode(' '));
li.appendChild(commentSpan);
}
if (entry.revtype !== 'deleted') {
li.appendChild(afContainer);
}
return li;
}
});