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;

}

});