mic_none

User:Daniel Quinlan/Scripts/FilterHits.js Source: en.wikipedia.org/wiki/User:Daniel_Quinlan/Scripts/FilterHits.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, relevantUser, isSysop) {
		this.api = api;
		this.special = special;
		this.relevantUser = relevantUser;
		this.isSysop = isSysop;
		this.revElements = {};
		this.noRevElements = {};
		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');
		this.isoTimezone = this.getIsoTimezone();
		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.revElements[revid] = li;
			if (!this.firstRevid) {
				this.firstRevid = revid;
			}
			this.lastRevid = revid;
			this.timestamps[revid] = this.extractTimestamp(li);
		}
		this.userContribsPromise = this.fetchUserContribs();
		this.timestampsPromise = this.fetchRevisions();
		this.noRevids = {};
		this.noRevidIndex = 0;
	}

	getIsoTimezone() {
		if (mw.user.options.get('date') !== 'ISO 8601') return null;
		const correction = mw.user.options.get('timecorrection') || '';
		const match = correction.match(/^(?:Offset|System)\|(-)?(\d+)$/);
		if (!match) return null;
		const sign = match[1] || '+';
		const offset = Number(match[2]);
		const pad = n => String(n).padStart(2, '0');
		return `${sign}${pad(Math.floor(offset / 60))}:${pad(offset % 60)}`;
	}

	extractTimestamp(li) {
		if (this.special === 'DeletedContributions') return this.extractDeletedTimestamp(li);
		if (this.isoTimezone) return this.extractVisibleTimestamp(li);
		return null;
	}

	extractDeletedTimestamp(li) {
		const link = li.querySelector('.mw-deletedcontribs-tools > a[title="Special:Undelete"]');
		if (!link) return null;
		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`;
	}

	extractVisibleTimestamp(li) {
		const text = li.querySelector('.mw-changeslist-date')?.textContent
		if (!text) return null;
		const match = text.match(/^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)/);
		if (!match) return null;
		const textTime = match[1];
		if (this.isoTimezone === '+00:00') return textTime + 'Z';
		const date = new Date(textTime + this.isoTimezone);
		if (isNaN(date)) return null;
		return date.toISOString().replace(/\.\d+Z$/, 'Z');
	}

	async fetchUserContribs() {
		if (!this.relevantUser || !this.hasMissingTimestamps()) return;
		incrementCounter('usercontribs');
		const extra = n => n + Math.ceil(Math.log10(n / 10 + 1));
		const limit = this.isSysop ? 5000 : 500;
		const neededCount = extra(Object.keys(this.revElements).length);
		const urlParams = new URLSearchParams(location.search);
		const dir = urlParams.get('dir');
		const offset = urlParams.get('offset');
		const isPrev = dir === 'prev';
		let currentLimit = Math.min(neededCount, isPrev ? limit : extra(100));
		const baseParams = {
			action: 'query',
			list: 'usercontribs',
			ucprop: 'ids|timestamp',
			ucuser: this.relevantUser,
			format: 'json'
		};
		if (this.hasNewer || this.hasOlder) {
			baseParams.ucdir = isPrev ? 'newer' : 'older';
			if (offset && this.hasOlder) {
				baseParams.ucstart = offset;
			}
		}
		let later = null;
		let requested = 0;
		let continueParams = {};
		while (requested < neededCount) {
			incrementCounter('usercontribs-query');
			const params = { ...baseParams, ...continueParams, uclimit: currentLimit };
			const data = await this.api.get(params);
			const contribs = data?.query?.usercontribs || [];
			requested += currentLimit;
			for (const contrib of contribs) {
				if (contrib.revid) {
					if (contrib.revid in this.revElements) {
						this.timestamps[contrib.revid] = contrib.timestamp;
					}
					if (!isPrev && contrib.revid < this.lastRevid && (!later || contrib.revid > later.revid)) {
						later = contrib;
					}
				}
			}
			if (!data?.continue || requested >= neededCount) break;
			continueParams = data.continue;
			currentLimit = Math.min(limit, neededCount - requested);
		}
		if (later) {
			this.nextRevid = later.revid;
			this.timestamps[later.revid] = later.timestamp;
		}
	}

	hasMissingTimestamps() {
		return Object.values(this.timestamps).some(ts => ts === null);
	}

	async fetchRevisions(revisions) {
		let mode;
		if (revisions) {
			mode = 'revisions';
		} else {
			mode = 'missing';
			await this.userContribsPromise;
			revisions = Object.keys(this.timestamps).filter(r => this.timestamps[r] === null);
		}
		if (!revisions.length) return;
		incrementCounter(`revisions-${mode}`);
		revisions.unshift(revisions.pop());
		const limit = this.isSysop ? 500 : 50;
		for (let i = 0; i < revisions.length; i += limit) {
			incrementCounter(`revisions-${mode}-query`);
			const chunk = revisions.slice(i, i + limit);
			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-${caller}`)
		if (!this.lastRevid || !this.hasOlder) return;
		const link = document.querySelector('a.mw-nextlink');
		if (!link?.href) return;
		const url = new URL(link.href);
		if (this.relevantUser) {
			const offset = url.searchParams.get('offset');
			if (!offset) return;
			const params = {
				action: 'query',
				list: 'usercontribs',
				uclimit: 20,
				ucstart: offset,
				ucprop: 'ids|timestamp',
				ucuser: this.relevantUser,
				format: 'json',
			};
			incrementCounter(`next-revid-user-${caller}`)
			const data = await this.api.get(params);
			const next = data?.query?.usercontribs?.find(c => Number(c.revid) < this.lastRevid);
			if (!next) return;
			this.nextRevid = next.revid;
			this.timestamps[next.revid] = next.timestamp;
		} else {
			url.searchParams.set('limit', '20');
			incrementCounter(`next-revid-nouser-${caller}`)
			const response = await fetch(url);
			if (!response.ok) return;
			const html = await response.text();
			const fetched = new DOMParser().parseFromString(html, 'text/html');
			const listItems = fetched.querySelectorAll('ul.mw-contributions-list > li[data-mw-revid]');
			for (const li of listItems) {
				const revid = Number(li.getAttribute('data-mw-revid'));
				if (revid && revid < this.lastRevid) {
					this.nextRevid = revid;
					this.timestamps[revid] = this.extractTimestamp(li);
					return;
				}
			}
		}
	}

	async getTimestamp(revid) {
		if (this.timestamps[revid]) {
			return this.timestamps[revid];
		}
		if (revid && revid === this.nextRevid) {
			this.nextRevidTimestampPromise ||= this.fetchRevisions([revid]);
			await this.nextRevidTimestampPromise;
		} else {
			await this.timestampsPromise;
		}
		return this.timestamps[revid];
	}

	async getNextRevid(caller) {
		if (this.nextRevid !== null) {
			return this.nextRevid;
		}
		await this.userContribsPromise;
		if (this.nextRevid !== null) {
			return this.nextRevid;
		}
		this.nextRevidPromise ||= this.fetchNextRevid(caller);
		await this.nextRevidPromise;
		return this.nextRevid;
	}

	createNoRevid(string) {
		return "norev" + (this.noRevids[string] ??= --this.noRevidIndex);
	}
}

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, relevantUser, isSysop);
	const contentChanges = new Set();
	let showUser;
	let toggleButtonDisplayed = false;
	incrementCounter('script-run');
	addFilterLogCSS();
	const buttonContainer = createButtonContainer();
	if (!buttonContainer) return;
	addToggleButton();
	if (relevantUser) {
		if (!ensureContributionsList(revisionData)) return;
		incrementCounter('mode-single');
		showUser = false;
		await processUser(relevantUser);
	} else {
		incrementCounter('mode-multiple');
		showUser = true;
		const { users, additional } = getMultipleUsers();
		const processUsersPromise = processUsers(users);
		if (additional.size) {
			processAdditional(additional, processUsersPromise, !users.size);
		}
	}
	saveCounters();

	function addFilterLogCSS() {
		mw.util.addCSS(`
			.abusefilter-container {
				display: inline-block;
			}
			.abusefilter-container::before {
				content: "[";
				padding-right: 0.1em;
			}
			.abusefilter-container::after {
				content: "]";
				padding-left: 0.1em;
			}
			.abusefilter-logid {
				display: inline-block;
			}
			.abusefilter-logid-tag, .abusefilter-logid-tag > a {
				color: var(--color-content-added, #348469);
			}
			.abusefilter-logid-showcaptcha, .abusefilter-logid-showcaptcha > a {
				color: var(--color-content-removed, #d0450b);
			}
			.abusefilter-logid-warn, .abusefilter-logid-warn > a {
				color: var(--color-warning, #957013);
			}
			.abusefilter-logid-disallow, .abusefilter-logid-disallow > a {
				color: var(--color-error, #e90e01);
			}
			.abusefilter-logid-warned, .abusefilter-logid-warned > a {
				text-decoration: underline;
				text-decoration-color: var(--color-warning, #957013);
				text-decoration-thickness: 1.25px;
				text-underline-offset: 1.25px;
			}
			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 = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="2" cy="12" r="1"/><circle cx="6" cy="12" r="1"/><circle cx="10" cy="12" r="1"/><circle cx="14" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><circle cx="22" cy="12" r="1"/></g><path d="M12 9V1M12 1L9 5M12 1L15 5M12 15V23M12 23L9 19M12 23L15 19" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';
		const collapseIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="currentColor"><circle cx="2" cy="1" r="1"/><circle cx="6" cy="1" r="1"/><circle cx="10" cy="1" r="1"/><circle cx="14" cy="1" r="1"/><circle cx="18" cy="1" r="1"/><circle cx="22" cy="1" r="1"/><circle cx="2" cy="23" r="1"/><circle cx="6" cy="23" r="1"/><circle cx="10" cy="23" r="1"/><circle cx="14" cy="23" r="1"/><circle cx="18" cy="23" r="1"/><circle cx="22" cy="23" r="1"/></g><path d="M12 3.25V10.5M12 10.5L9 6.5M12 10.5L15 6.5M12 20.75V13.5M12 13.5L9 17.5M12 13.5L15 17.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';
		const pager = document.querySelector('.mw-pager-body');
		if (!pager) return;
		const button = createButton('toggle', 'Collapse unfiltered', collapseIcon);
		button.style.display = 'none';
		buttonContainer.append(button);
		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';
		});
	}

	function createButtonContainer() {
		const form = document.querySelector('.mw-htmlform');
		if (!form) return;
		const legend = form.querySelector('legend');
		if (!legend) return;
		legend.style.display = 'flex';
		const buttonContainer = document.createElement('div');
		buttonContainer.style.marginLeft = 'auto';
		buttonContainer.style.display = 'flex';
		buttonContainer.style.gap = '12px';
		legend.append(buttonContainer);
		return buttonContainer;
	}

	function createButton(name, title, icon) {
		const button = document.createElement('button');
		button.type = 'button';
		button.className = `unfiltered-${name}-button`;
		button.title = title;
		button.innerHTML = icon;
		button.style.cssText = `
			background: none;
			border: none;
			cursor: pointer;
			width: 24px;
			height: 24px;
			padding: 0;
			margin-left: auto;
			vertical-align: middle;
		`;
		return 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.append(ul);
			} else {
				return false;
			}
		}
		return true;
	}

	function getMultipleUsers() {
		const links = document.querySelectorAll('ul.mw-contributions-list li a.mw-anonuserlink');
		const users = new Set();
		const additional = new Set();
		for (const link of links) {
			users.add(link.textContent.trim());
		}
		for (const ip of enumerateSmallIPv4Range(mw.config.get('wgPageName'))) {
			if (!users.has(ip)) {
				additional.add(ip);
			}
		}
		return { users, additional };
	}

	function enumerateSmallIPv4Range(input) {
		const m = input.match(/^[^\/]+\/((?:1?\d\d?|2[0-4]\d|25[0-5])(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3})\/(2[4-9]|3[0-2])\b/);
		if (!m) return [];
		const ip = m[1].split('.').reduce((acc, oct) => (acc << 8n) + BigInt(oct), 0n);
		const mask = Number(m[2]);
		const count = 1n << BigInt(32 - mask);
		const base = ip & ~(count - 1n);
		return Array.from({ length: Number(count) }, (_, i) => {
			const ipValue = base + BigInt(i);
			return [
				(ipValue >> 24n) & 255n,
				(ipValue >> 16n) & 255n,
				(ipValue >> 8n) & 255n,
				ipValue & 255n,
			].join('.');
		});
	}

	async function processUsers(users) {
		for (const user of users) {
			incrementCounter('mode-multiple-user');
			await processUser(user);
		}
	}

	function processAdditional(ips, processUsersPromise, autoClick) {
		const processTalkUsersPromise = processTalkUsers(ips, processUsersPromise);
		const queryIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><circle class="query-icon-ring" cx="12" cy="12" r="10" fill="none" stroke="gray" stroke-width="2" stroke-dasharray="62.832" stroke-dashoffset="62.832" transform="rotate(-90 12 12)"/><text class="query-icon-mark" x="12" y="16" text-anchor="middle" font-size="14" fill="currentColor">?</text></svg>';
		const button = createButton('query', 'Query additional addresses', queryIcon);
		buttonContainer.prepend(button);
		let running = false;
		button.addEventListener('click', async (e) => {
			e.stopPropagation();
			if (running) return;
			running = true;
			button.querySelector('.query-icon-mark').setAttribute('fill', 'gray');
			button.title = 'Querying additional addresses';
			await processTalkUsersPromise;
			const ring = button.querySelector('.query-icon-ring');
			let count = 0, circumference = 20 * Math.PI;
			for (const ip of ips) {
				incrementCounter('mode-multiple-additional');
				await processUser(ip);
				ring.setAttribute('stroke-dashoffset', ((1 - ++count / ips.size) * circumference));
			}
			button.title = `Queried ${ips.size} addresses`;
		});
		if (autoClick && ensureContributionsList(revisionData)) {
			button.click();
		}
	}

	async function processTalkUsers(ips, processUsersPromise) {
		function batch(items, maxSize) {
			const minBins = Math.ceil(items.length / maxSize);
			const bins = Array.from({length: minBins}, () => []);
			items.forEach((item, i) => {
				bins[i % minBins].push(item);
			});
			return bins;
		}
		await processUsersPromise;
		const talkTitles = Array.from(ips).map(ip => `User talk:${ip}`);
		const infoResponses = await Promise.all(
			batch(talkTitles, 50).map(titles => api.get({
				action: 'query',
				titles: titles.join('|'),
				prop: 'info|revisions',
				format: 'json',
				formatversion: 2
			}))
		);
		const talkUsers = infoResponses.flatMap(response =>
			response.query.pages
				.filter(page => !page.missing)
				.map(page => page.title.replace(/^User talk:/, ''))
		);
		for (const ip of talkUsers) {
			incrementCounter('mode-multiple-talk');
			await processUser(ip);
			ips.delete(ip);
		}
	}

	async function processUser(user) {
		const start = await getStartValue(revisionData);
		if (special === 'Contributions') {
			const abusePromise = fetchAbuseLog(user, start);
			const deletedPromise = isSysop ? fetchDeletedRevisions(user, start) : Promise.resolve();
			const [remainingHits] = await Promise.all([abusePromise, deletedPromise]);
			await updateRevisions(remainingHits, true);
		} else {
			await fetchAbuseLog(user, start);
		}
		if (contentChanges.size > 0) {
			mutex.run(() => {
				mw.hook('wikipage.content').fire($([...contentChanges]));
				contentChanges.clear();
			});
		}
	}

	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;
	}

	async function fetchAbuseLog(user, start) {
		function updateWarned(warned) {
			for (const [revid, filterText] of warned) {
				const li = revisionData.revElements[revid];
				if (!li) return;
				const filters = li.querySelectorAll('.abusefilter-container .abusefilter-logid');
				for (let i = filters.length - 1; i >= 0; i--) {
					const filter = filters[i];
					if (filter.textContent === filterText) {
						filter.classList.add('abusefilter-logid-warned');
						break;
					}
				}
			}
		}
		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 warned = new Map();
			start = data?.continue?.aflstart || null;
			for (const entry of logs) {
				const revid = entry.revid;
				if (revisionData.lastRevid) {
					if (revid) {
						if (Number(revid) < revisionData.lastRevid) {
							excessEntryCount++;
						}
					} else {
						const lastTimestamp = await revisionData.getTimestamp(revisionData.lastRevid);
						if (entry.timestamp < lastTimestamp) {
							excessEntryCount++;
						}
					}
				} else {
					excessEntryCount++;
				}
				const warnedKey = `${entry.filter_id}|${entry.filter}|${entry.title}|${entry.user}`;
				if (revid) {
					revisionMap.set(warnedKey, revid);
				} else if (entry.result === 'warn') {
					const warnedRevid = revisionMap.get(warnedKey);
					if (warnedRevid) {
						const filterText = entry.filter_id ?? entry.filter;
						warned.set(warnedRevid, filterText);
						revisionMap.delete(warnedKey);
					}
				}
				entry.filter_id = entry.filter_id || 'private';
				entry.result = entry.result || 'none';
				entry.userstring = user;
				if (revid) {
					entry.revtype = revisionData.revElements[revid] ? 'matched' : 'unmatched';
					hits[revid] ??= [];
					hits[revid].push(entry);
				} else if (special === 'Contributions') {
					const editKey = `${entry.timestamp}>${entry.title}>${entry.user}`;
					const norevid = revisionData.createNoRevid(editKey);
					entry.revtype = 'no-revision';
					entry.norevid = norevid;
					hits[norevid] ??= [];
					hits[norevid].push(entry);
				}
			}
			if (excessEntryCount >= limit) {
				start = null;
			}
			await updateRevisions(hits);
			if (warned.size) {
				updateWarned(warned);
			}
		} while (start);
		return hits;
	}

	async function fetchDeletedRevisions(user, start) {
		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, tooOld } = await checkBounds(entry, 'deleted');
					if (!tooNew && !tooOld) {
						entry.title = page.title;
						entry.userstring = user;
						entry.revtype = 'deleted';
						const li = createListItem(entry);
						insertListItem(li);
					}
					if (tooOld) return;
				}
			}
			adrcontinue = data?.continue?.adrcontinue || null;
		} while (adrcontinue);
	}

	async function checkBounds(entry, type) {
		const { hasNewer, hasOlder, firstRevid, lastRevid } = revisionData;
		const hasRevid = Boolean(entry.revid);
		const entryValue = hasRevid ? Number(entry.revid) : entry.timestamp;
		const getDataValue = hasRevid
			? id => Number(id)
			: async id => await revisionData.getTimestamp(id);
		let tooNew = false;
		let tooOld = false;
		if (hasNewer && firstRevid) {
			const firstValue = await getDataValue(firstRevid);
			if (firstValue && entryValue > firstValue) {
				tooNew = true;
			}
		}
		if (!tooNew && hasOlder && lastRevid) {
			const lastValue = await getDataValue(lastRevid);
			if (lastValue && entryValue <= lastValue) {
				const nextRevid = await revisionData.getNextRevid(type);
				if (nextRevid) {
					const nextValue = await getDataValue(nextRevid);
					if (nextValue && entryValue <= nextValue) {
						tooOld = true;
					}
				}
			}
		}
		return { tooNew, tooOld };
	}

	async function updateRevisions(hits, finalUpdate = false) {
		const matched = [];
		for (const revid in hits) {
			let li = revisionData.revElements[revid] || revisionData.noRevElements[revid];
			if (!li && (revid.startsWith('norev') || finalUpdate)) {
				const first = hits[revid][0];
				const { tooNew, tooOld } = await checkBounds(first, first.revtype);
				if (!tooNew && !tooOld) {
					if (first.revtype === 'unmatched') first.revtype = 'removed';
					li = createListItem(first);
					insertListItem(li);
				}
			}
			if (!li) continue;
			let container = li.querySelector('.abusefilter-container');
			if (!container) {
				container = document.createElement('span');
				container.className = 'abusefilter-container';
				li.append(document.createTextNode(' '), container);
			}
			for (const entry of hits[revid]) {
				if (container.firstChild) {
					container.prepend(document.createTextNode(' '));
				}
				container.prepend(createFilterElement(entry));
			}
			matched.push(revid);
			contentChanges.add(li);
		}
		for (const revid of matched) {
			delete hits[revid];
		}
	}

	function insertListItem(li) {
		return mutex.run(() => insertListItemUnsafe(li));
	}

	async function insertListItemUnsafe(li) {
		if (!toggleButtonDisplayed) {
			const button = document.querySelector('.unfiltered-toggle-button');
			if (button) {
				toggleButtonDisplayed = true;
				button.style.display = '';
			}
		}
		const allLis = Array.from(document.querySelectorAll('ul.mw-contributions-list > li'));
		const newRevid = li.getAttribute('data-revid');
		for (const existingLi of allLis) {
			const revid = existingLi.getAttribute('data-mw-revid') || existingLi.getAttribute('data-revid');
			if (newRevid && revid && Number(newRevid) > Number(revid)) {
				existingLi.parentElement.insertBefore(li, existingLi);
				return;
			}
			const dataTimestamp = existingLi.getAttribute('data-timestamp');
			const ts = dataTimestamp ?? (revid ? await revisionData.getTimestamp(revid) : null);
			if (!ts) return;
			const newTimestamp = li.getAttribute('data-timestamp');
			if (newTimestamp > ts) {
				existingLi.parentElement.insertBefore(li, existingLi);
				return;
			}
		}
		const lastUl = document.querySelectorAll('ul.mw-contributions-list');
		if (lastUl.length) {
			lastUl[lastUl.length - 1]?.append(li);
		}
	}

	function createFilterElement(entry) {
		const element = document.createElement('span');
		element.className = `abusefilter-logid abusefilter-logid-${entry.result}`;
		element.title = entry.filter;
		if (entry.filter_id !== 'private') {
			const link = document.createElement('a');
			link.href = `https://demo.azizisearch.com/lite/wikipedia/page/Special:AbuseLog/${entry.id}`;
			link.textContent = entry.filter_id;
			element.append(link);
		} else {
			element.textContent = 'private';
		}
		return element;
	}

	function createListItem(entry) {
		const revTypeLabel = {
			'deleted': 'Deleted',
			'no-revision': 'No revision',
			'removed': 'Removed'
		};
		const li = document.createElement('li');
		li.className = `mw-contributions-${entry.revtype}`;
		if (entry.revid) {
			li.setAttribute('data-revid', entry.revid);
		} else {
			li.setAttribute('data-norevid', entry.norevid);
		}
		li.setAttribute('data-timestamp', entry.timestamp);
		const pageTitleEncoded = mw.util.wikiUrlencode(entry.title);
		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=${pageTitleEncoded}&timestamp=${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;
		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 = `https://demo.azizisearch.com/lite/wikipedia/page/${pageTitleEncoded}`;
			titleLink.className = 'mw-contributions-title';
			titleLink.title = entry.title;
		}
		titleBdi.append(titleLink);
		titleSpanWrapper.append(titleBdi);
		li.append(timestamp, ' ');
		const sep1 = document.createElement('span');
		sep1.className = 'mw-changeslist-separator';
		li.append(sep1, ' ');
		const label = document.createElement('span');
		label.textContent = revTypeLabel[entry.revtype] || entry.revtype;
		label.style.fontStyle = 'italic';
		li.append(label, ' ');
		const sep2 = document.createElement('span');
		sep2.className = 'mw-changeslist-separator';
		li.append(sep2, ' ');
		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.append(' ', minorAbbr, ' ');
			}
			if (entry.parentid === 0) {
				const newAbbr = document.createElement('abbr');
				newAbbr.className = 'newpage';
				newAbbr.title = 'This edit created a new page';
				newAbbr.textContent = 'N';
				li.append(' ', newAbbr, ' ');
			}
		}
		li.append(titleSpanWrapper);
		if (showUser && entry.user) {
			li.append(' ');
			const sep3 = document.createElement('span');
			sep3.className = 'mw-changeslist-separator';
			li.append(sep3, ' ');
			const userBdi = document.createElement('bdi');
			userBdi.setAttribute('dir', 'ltr');
			userBdi.className = 'mw-userlink mw-anonuserlink';
			const userLink = document.createElement('a');
			const userEncoded = mw.util.wikiUrlencode(entry.user);
			userLink.href = `https://demo.azizisearch.com/lite/wikipedia/page/Special:Contributions/${userEncoded}`;
			userLink.className = 'mw-userlink mw-anonuserlink';
			userLink.title = `Special:Contributions/${entry.user}`;
			userLink.textContent = entry.userstring || entry.user;
			userBdi.append(userLink);
			li.append(userBdi);
		}
		if (entry.revtype === 'deleted' && entry.parsedcomment) {
			const commentSpan = document.createElement('span');
			commentSpan.className = 'comment';
			commentSpan.innerHTML = `(${entry.parsedcomment})`;
			li.append(' ', commentSpan);
		}
		if (entry.revid) {
			revisionData.revElements[entry.revid] = li;
		} else {
			revisionData.noRevElements[entry.norevid] = li;
		}
		contentChanges.add(li);
		return li;
	}
});