User:Daniel Quinlan/Scripts/Unfiltered.js

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.
'use strict';

mw.loader.using(['mediawiki.api', 'mediawiki.language', 'mediawiki.storage', 'mediawiki.user', 'mediawiki.util', 'mediawiki.DateFormatter', 'mediawiki.ForeignApi']).then(async () => {
	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(context, api, rights) {
			this.context = context;
			this.api = api;
			this.rights = rights;
			this.revElements = {};
			this.noRevElements = {};
			this.timestamps = {};
		}

		addRevid(revid, element, timestamp) {
			this.revElements[revid] = element;
			this.timestamps[revid] = timestamp;
			if (!this.firstRevid || revid > this.firstRevid) this.firstRevid = revid;
			if (!this.lastRevid || revid < this.lastRevid) this.lastRevid = revid;
		}

		extractDeletedTimestamp(element) {
			const link = element.querySelector('a[href*="Special:Undelete"]:where([href*="&timestamp="],[href*="?timestamp="])');
			if (!link) return null;
			const match = link.href.match(/[&?]timestamp=(\d{14})(?:&|$)/);
			if (!match) return null;
			return mwToIso(match[1]);
		}
	}

	class DiffData extends RevisionData {
		constructor(context) {
			super(context);
			for (const td of document.querySelectorAll('.diff-otitle, .diff-ntitle')) {
				const revid = this.getRevid(td);
				if (!revid) continue;
				const lastDiv = Array.from(td.querySelectorAll(':scope > div'))
					.filter(d => !d.querySelector('#differences-prevlink, #differences-nextlink'))
					.at(-1);
				if (!lastDiv) continue;
				if (context.deleted && lastDiv.lastChild?.nodeName === 'BR') {
					lastDiv.removeChild(lastDiv.lastChild);
				}
				this.addRevid(revid, lastDiv, this.extractTimestamp(td));
			}
		}

		getRevid(td) {
			return Number(td.querySelector('[data-mw-revid]')?.getAttribute('data-mw-revid'));
		}

		extractTimestamp(title) {
			if (this.context.deleted) return this.extractDeletedTimestamp(title);
			return title.querySelector('.mw-diff-timestamp')?.getAttribute('data-timestamp');
		}

		async getTimestamp(revid) {
			return this.timestamps[revid];
		}
	}

	class ListingData extends RevisionData {
		constructor(context, api, rights) {
			super(context, api, rights);
			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 selector = this.context.type === 'page' && this.context.deleted
				? 'ul.mw-undelete-revlist > li'
				: 'ul.mw-contributions-list > li[data-mw-revid]';
			for (const li of document.querySelectorAll(selector)) {
				const revid = this.getRevid(li);
				if (!revid) continue;
				this.addRevid(revid, li, this.extractTimestamp(li));
			}
			this.fetchTimestampsPromise = this.fetchTimestamps();
			this.fetchRevisionsPromise = 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)}`;
		}

		getRevid(li) {
			return Number(li.getAttribute('data-mw-revid') || li.querySelector('[data-mw-revid]')?.getAttribute('data-mw-revid'));
		}

		extractTimestamp(li) {
			if (this.context.deleted) return this.extractDeletedTimestamp(li);
			if (this.isoTimezone) return this.extractVisibleTimestamp(li);
			return null;
		}

		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 fetchTimestamps() {
			if (this.context.deleted) return;
			if (!this.context.user && !this.context.page) return;
			if (!this.hasMissingTimestamps()) return;
			const extra = n => n + Math.ceil(Math.log10(n / 10 + 1)) + 1;
			const limit = this.rights.apihighlimits ? 5000 : 500;
			const neededCount = extra(Object.keys(this.revElements).length);
			let currentLimit = Math.min(limit, neededCount);
			const { baseParams, paramPrefix } = this.buildChangesParams(currentLimit);
			let isPrev = false;
			if (this.context.page) {
				if (this.firstRevid) {
					baseParams.rvstartid = this.firstRevid;
					baseParams.rvdir = 'older';
				}
			} else {
				const urlParams = new URLSearchParams(location.search);
				const dir = urlParams.get('dir');
				const offset = urlParams.get('offset');
				isPrev = dir === 'prev';
				if (this.hasNewer || this.hasOlder) {
					baseParams[`${paramPrefix}dir`] = isPrev ? 'newer' : 'older';
					if (offset && this.hasOlder) {
						baseParams[`${paramPrefix}start`] = offset;
					}
				}
			}
			let later = null;
			let received = 0;
			let continueToken = null;
			do {
				const params = {
					...baseParams,
					...(continueToken || {}),
					[`${paramPrefix}limit`]: currentLimit
				};
				const data = await this.api.get(params);
				const revisions = this.extractRevisions(data);
				if (!revisions.length) break;
				received += revisions.length;
				for (const rev of revisions) {
					if (rev.revid) {
						if (rev.revid in this.revElements) {
							this.timestamps[rev.revid] = rev.timestamp;
						}
						if (!isPrev && rev.revid < this.lastRevid && (!later || rev.revid > later.revid)) {
							later = rev;
						}
					}
				}
				if (received >= neededCount) break;
				continueToken = data?.continue;
				currentLimit = Math.min(limit, neededCount - received);
			} while (continueToken);
			if (later) {
				this.nextRevid = later.revid;
				this.timestamps[later.revid] = later.timestamp;
			}
		}

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

		buildChangesParams(limit) {
			const paramPrefix = this.context.type === 'page' ? 'rv' : 'uc';
			const baseParams = {
				action: 'query',
				[`${paramPrefix}limit`]: limit,
				[`${paramPrefix}prop`]: 'ids|timestamp',
				format: 'json'
			};
			if (this.context.type === 'page') {
				baseParams.prop = 'revisions';
				baseParams.titles = this.context.page;
				baseParams.rvslots = 'main';
			} else {
				baseParams.list = 'usercontribs';
				baseParams.ucuser = this.context.user;
			}
			return { baseParams, paramPrefix };
		}

		extractRevisions(data) {
			return this.context.type === 'page'
				? Object.values(data?.query?.pages || {})[0]?.revisions || []
				: data?.query?.usercontribs || [];
		}

		async fetchRevisions(revisions) {
			if (this.context.deleted) return;
			let mode;
			if (revisions) {
				mode = 'revisions';
			} else {
				mode = 'missing';
				await this.fetchTimestampsPromise;
				revisions = Object.keys(this.timestamps).filter(r => this.timestamps[r] === null);
			}
			if (!revisions.length) return;
			revisions.unshift(revisions.pop());
			const limit = this.rights.apihighlimits ? 500 : 50;
			for (let i = 0; i < revisions.length; i += limit) {
				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'
				});
				for (const page of Object.values(data?.query?.pages || {})) {
					for (const rev of page.revisions || []) {
						this.timestamps[rev.revid] = rev.timestamp;
					}
				}
			}
		}

		async fetchNextRevid(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.context.page || this.context.user) {
				const { baseParams, paramPrefix } = this.buildChangesParams(20);
				if (this.context.page) {
					baseParams.rvstartid = this.lastRevid;
				} else {
					const offset = url.searchParams.get('offset');
					if (!offset) return;
					baseParams[`${paramPrefix}start`] = offset;
				}
				const data = await this.api.get(baseParams);
				const revisions = this.extractRevisions(data);
				const next = revisions.find(rev => Number(rev.revid) < this.lastRevid);
				if (next) {
					this.nextRevid = next.revid;
					this.timestamps[next.revid] = next.timestamp;
					return;
				}
			}
			url.searchParams.set('limit', '20');
			const response = await fetch(url);
			if (!response.ok) return;
			const html = await response.text();
			const fetched = new DOMParser().parseFromString(html, 'text/html');
			const items = fetched.querySelectorAll('ul.mw-contributions-list > li[data-mw-revid]');
			for (const li of items) {
				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.fetchRevisionsPromise;
			}
			return this.timestamps[revid];
		}

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

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

	class RecentData extends RevisionData {
		constructor(context) {
			super(context);
			this.pages = new Map();
			const now = new Date().toISOString();
			for (const li of document.querySelectorAll('li.mw-changeslist-line[data-mw-revid]:not(.unfiltered-processed)')) {
				const revid = li.getAttribute('data-mw-revid');
				if (!revid) continue;
				const ts = mwToIso(li.getAttribute('data-mw-ts'));
				this.addRevid(revid, li, ts);
				li.classList.add('unfiltered-processed');
				li.setAttribute('processed', now);
				if (context.type === 'wl') {
					const pageElement = li.querySelector('[data-target-page]');
					if (pageElement) {
						const page = pageElement.getAttribute('data-target-page');
						if (!this.pages.has(page)) {
							this.pages.set(page, ts);
						}
					}
				}
			}
		}

		async getTimestamp(revid) {
			return this.timestamps[revid];
		}
	}

	// get context
	const context = getContext();
	if (!context) return;

	// constants
	const ACTION_API = 'https://en.wikipedia.org/w/api.php';
	const UNFILTERED_JSON = 'User:Daniel Quinlan/Scripts/Unfiltered.json';
	const ABUSELOG_BUFFER = 30;
	const DEFAULT_OPTIONS = { user: true, page: true };
	const DEFAULT_ALIASES = {
		'abuselog': 'AbuseLog',
		'contributions': 'Contributions',
		'log': 'Log',
		'undelete': 'Undelete',
	};
	const DEFAULT_MESSAGES = {
		'abusefilter-hidden': 'Private',
		'cur': 'cur',
		'diff': 'diff',
		'hide': 'Hide',
		'hist': 'hist',
		'last': 'prev',
		'log-name-abusefilterblockeddomainhit': 'Blocked domains hit log',
		'log-name-spamblacklist': 'Spam block list log',
		'minoreditletter': 'm',
		'newpage': 'New page',
		'newpageletter': 'N',
		'recentchanges-label-minor': 'This is a minor edit',
		'red-link-title': '$1 (page does not exist)',
		'show': 'Show',
	};
	const INTERNAL_STRINGS = {
		'query-addresses': 'Query additional addresses',
		'revision-deleted': 'Deleted',
		'revision-no-revision': 'No revision',
		'revision-removed': 'Removed',
	};

	// main processing
	const formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
	const articlePath = mw.config.get('wgArticlePath')?.replace(/\$1$/, '') || '/wiki/';
	const scriptPath = mw.config.get('wgScript') || '/w/index.php';
	const userLanguage = mw.config.get('wgUserLanguage') || 'en';
	const specialName = mw.config.get('wgFormattedNamespaces')?.['-1'] || 'Special';
	const api = new mw.Api();
	const mutex = new Mutex();
	const rights = await getRights();
	const strings = await getStrings();
	const addedTitles = [];
	const contentChanges = new Set();
	const includeFields = new Set();
	let revisionData;
	let firstInsert = false;
	addFilterLogCSS();
	if (context.type === 'page' || context.type === 'user') {
		revisionData = new ListingData(context, api, rights);
		if (context.page) {
			if (!ensureContributionsList(revisionData)) return;
			includeFields.add('user');
			await processTarget(context.page, 'page');
		} else if (context.user) {
			if (!ensureContributionsList(revisionData)) return;
			includeFields.add('title');
			await processTarget(context.user);
		} else {
			includeFields.add('title');
			includeFields.add('user');
			const { users, additional } = getMultipleUsers();
			const processUsersPromise = processUsers(users);
			if (additional.size) {
				processAdditional(additional, processUsersPromise, !users.size);
			}
		}
	} else if (context.type === 'diff') {
		await processDiff();
	} else if (context.type === 'rc' || context.type === 'wl') {
		await processRecent();
		const container = document.querySelector('.mw-changeslist');
		if (!container) return;
		const recentMutex = new Mutex();
		new MutationObserver(() => recentMutex.run(() => processRecent()))
			.observe(container, { childList: true });
	}

	function getContext() {
		const special = mw.config.get('wgCanonicalSpecialPageName');
		const deleted = special === 'DeletedContributions' || special === 'Undelete';
		if (special === 'Contributions' || special === 'DeletedContributions') {
			return { type: 'user', deleted, user: mw.config.get('wgRelevantUserName') };
		}
		const page = mw.config.get('wgRelevantPageName')?.replace(/_/g, ' ');
		if (!page) return null;
		if (mw.config.get('wgAction') === 'history' || (special === 'Undelete' && document.querySelector('ul.mw-undelete-revlist'))) {
			return { type: 'page', deleted, page };
		}
		if ((mw.config.get('wgDiffNewId') && mw.config.get('wgDiffOldId')) || (special === 'Undelete' && document.querySelector('table.diff'))) {
			return { type: 'diff', deleted, page };
		}
		if (special === 'Recentchanges') {
			return { type: 'rc', deleted };
		}
		if (special === 'Watchlist') {
			return { type: 'wl', deleted };
		}
		return null;
	}

	async function getRights() {
		const siteId = mw.config.get('wgWikiID') || 'unknown';
		const groups = (mw.config.get('wgUserGroups') || []).sort().join('|');
		const rightsKey = `unfiltered-rights-${siteId}-${groups}`;
		const cached = mw.storage.getObject(rightsKey);
		if (cached) return cached;
		const userRights = await mw.user.getRights();
		const wanted = ['apihighlimits', 'block', 'deletedhistory', 'deletedtext'];
		const rights = Object.fromEntries(wanted.map(r => [r, userRights.includes(r)]));
		mw.storage.setObject(rightsKey, rights, 86400);
		return rights;
	}

	async function getStrings() {
		const siteId = mw.config.get('wgWikiID') || 'unknown';
		const stringsKey = `unfiltered-strings-${siteId}-${userLanguage}`;
		const cached = mw.storage.getObject(stringsKey);
		const defaults = { ...DEFAULT_ALIASES, ...DEFAULT_MESSAGES, ...INTERNAL_STRINGS };
		if (cached) return { ...defaults, ...cached };
		const strings = {};
		const aliasesPromise = loadAliases();
		const languageChain = mw.language.getFallbackLanguageChain();
		try {
			const [loaded] = await Promise.all([
				loadTranslations(),
				api.loadMessages(Object.keys(DEFAULT_MESSAGES))
			]);
			for (const key of Object.keys(defaults)) {
				let replacement;
				if (DEFAULT_MESSAGES[key]) {
					const msg = mw.message(key);
					if (msg.exists()) {
						replacement = msg.plain();
					}
				}
				for (const language of languageChain) {
					const value = loaded[language]?.[key];
					if (value) {
						replacement = value;
						break;
					}
				}
				if (replacement) {
					strings[key] = replacement;
				}
			}
		} catch (error) {
			console.warn('Unfiltered: getStrings error', error);
		}
		const aliases = await aliasesPromise;
		Object.assign(strings, aliases);
		mw.storage.setObject(stringsKey, strings, 86400);
		return { ...defaults, ...strings };
	}

	async function loadAliases() {
		const aliases = { ...DEFAULT_ALIASES };
		try {
			const response = await api.get({
				action: 'query',
				format: 'json',
				formatversion: '2',
				meta: 'siteinfo',
				siprop: 'specialpagealiases'
			});
			const result = response?.query?.specialpagealiases || [];
			for (const [key, value] of Object.entries(DEFAULT_ALIASES)) {
				const found = result.find(item => item.realname === value);
				aliases[key] = found?.aliases?.[0] || value;
			}
		} catch (error) {
			console.warn('Unfiltered: loadAliases error', error);
		}
		return aliases;
	}

	async function loadTranslations() {
		const api = new mw.ForeignApi(ACTION_API);
		try {
			const data = await api.get({
				action: 'query',
				format: 'json',
				formatversion: 2,
				titles: UNFILTERED_JSON,
				prop: 'revisions',
				rvprop: 'content'
			});
			const content = data?.query?.pages?.[0]?.revisions?.[0]?.content;
			if (content) {
				return JSON.parse(content) || {};
			}
		} catch (error) {
			console.warn('Unfiltered: loadTranslations error', error);
		}
		return {};
	}

	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-content-text.unfiltered-hide li.mw-contributions-deleted,
			#mw-content-text.unfiltered-hide li.mw-contributions-no-revision,
			#mw-content-text.unfiltered-hide li.mw-contributions-removed {
				display: none;
			}
			.unfiltered-toggle-button {
				color: var(--color-subtle, gray);
			}
			.unfiltered-toggle-button:hover, .unfiltered-toggle-button:focus-visible {
				color: var(--color-base--hover, gray);
			}
			.unfiltered-icon-hide { display: inline; }
			.unfiltered-icon-show { display: none; }
			#mw-content-text.unfiltered-hide .unfiltered-icon-hide { display: none; }
			#mw-content-text.unfiltered-hide .unfiltered-icon-show { display: inline; }
		`);
	}

	async function addToggleButton() {
		const container = buttonContainer();
		if (!container) return;
		const hideIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="unfiltered-icon-hide" width="24" height="24" viewBox="0 0 24 24"><title>${strings.hide}</title><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 showIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="unfiltered-icon-show" width="24" height="24" viewBox="0 0 24 24"><title>${strings.show}</title><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 text = document.querySelector('#mw-content-text');
		if (!text) return;
		const options = await getOptions();
		if (!options[context.type]) text.classList.add('unfiltered-hide');
		const button = createButton('toggle', hideIcon, showIcon);
		container.append(button);
		let press = null;
		const clearTimer = () => {
			clearTimeout(press);
			press = null;
		};
		const toggleOption = async () => {
			clearTimer();
			options[context.type] = !options[context.type];
			const contextName = context.type === 'user' ? 'Contributions' : 'History';
			mw.notify(`${options[context.type] ? strings.hide : strings.show} \u2192 ${options[context.type] ? strings.show : strings.hide}`, { title: 'Unfiltered', type: 'success' });
			await saveOptions(options);
		};
		const toggleView = () => {
			clearTimer();
			text.classList.toggle('unfiltered-hide');
		};
		button.addEventListener('click', e => e.stopPropagation());
		button.addEventListener('pointerdown', e => {
			e.stopPropagation();
			press = setTimeout(toggleOption, 750);
		});
		button.addEventListener('pointerup', e => {
			e.stopPropagation();
			if (press) toggleView();
		});
		button.addEventListener('pointercancel', clearTimer);
		button.addEventListener('pointerleave', clearTimer);
		button.addEventListener('keydown', async e => {
			e.stopPropagation();
			if (e.key === 'Enter') toggleView();
			else if (e.key === ' ') await toggleOption();
		});
	}

	function buttonContainer() {
		if (context.deleted) return null;
		if (buttonContainer._element !== undefined) return buttonContainer._element;
		buttonContainer._element = null;
		const form = document.querySelector('.mw-htmlform');
		if (!form) return null;
		const legend = form.querySelector('legend');
		if (!legend) return null;
		legend.style.display = 'flex';
		const div = document.createElement('div');
		div.className = 'unfiltered-button-container';
		div.style.marginLeft = 'auto';
		div.style.display = 'flex';
		div.style.gap = '12px';
		legend.append(div);
		buttonContainer._element = div;
		return div;
	}

	function createButton(name, ...icons) {
		const button = document.createElement('button');
		button.type = 'button';
		button.className = `unfiltered-${name}-button`;
		button.innerHTML = icons.join('');
		button.style.cssText = `
			background: none;
			border: none;
			cursor: pointer;
			width: 24px;
			height: 24px;
			padding: 0;
			margin-left: auto;
			vertical-align: middle;
		`;
		return button;
	}

	async function getOptions() {
		try {
			const stored = mw.user.options.get('userjs-unfiltered') || '{}';
			return { ...DEFAULT_OPTIONS, ...JSON.parse(stored) };
		} catch (error) {
			console.error('Unfiltered: getOptions error', error);
			await saveOptions(DEFAULT_OPTIONS);
			return DEFAULT_OPTIONS;
		}
	}

	async function saveOptions(options) {
		try {
			const value = JSON.stringify(options);
			if (mw.user.isNamed()) await api.saveOption('userjs-unfiltered', value);
			mw.user.options.set('userjs-unfiltered', value);
		} catch (error) {
			console.error('Unfiltered: saveOptions error', error);
			throw error;
		}
	}

	function ensureContributionsList(revisionData) {
		if (!revisionData.lastRevid) {
			if (context.deleted) return false;
			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) {
			await processTarget(user);
		}
	}

	function processAdditional(ips, processUsersPromise, autoClick) {
		const container = buttonContainer();
		if (!container) return;
		const processTalkUsersPromise = processTalkUsers(ips, processUsersPromise);
		const queryIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>${strings['query-addresses']}</title><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', queryIcon);
		container.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');
			await processTalkUsersPromise;
			const ring = button.querySelector('.query-icon-ring');
			let count = 0, circumference = 20 * Math.PI;
			for (const ip of ips) {
				await processTarget(ip);
				ring.setAttribute('stroke-dashoffset', ((1 - ++count / ips.size) * circumference));
			}
		});
		if (autoClick && ensureContributionsList(revisionData)) {
			button.click();
		}
	}

	async function processTalkUsers(ips, processUsersPromise) {
		await processUsersPromise;
		const userTalkName = mw.config.get('wgFormattedNamespaces')?.[3] || 'User talk';
		const talkTitles = Array.from(ips).map(ip => `${userTalkName}:${ip}`);
		const existingTalkPages = await getExistingPages(talkTitles);
		const talkUsers = existingTalkPages.map(title => title.startsWith(userTalkName + ':') ? title.slice(userTalkName.length + 1) : title);
		for (const ip of talkUsers) {
			await processTarget(ip);
			ips.delete(ip);
		}
	}

	async function getExistingPages(titles) {
		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;
		}
		const responses = await Promise.all(
			batch(titles, 50).map(batchTitles =>
				api.get({
					action: 'query',
					titles: batchTitles.join('|'),
					prop: 'info',
					format: 'json',
					formatversion: 2
				})
			)
		);
		return responses.flatMap(response =>
			response.query.pages.filter(page => !page.missing).map(page => page.title)
		);
	}

	async function processDiff() {
		revisionData = new DiffData(context);
		const timestamps = Object.values(revisionData.timestamps);
		if (!timestamps.length) return;
		timestamps.sort();
		const start = offsetIso(timestamps.at(-1), ABUSELOG_BUFFER);
		const end = offsetIso(timestamps.at(0), -86400);
		try {
			await fetchHistory(context.page, 'page', start, end);
		} catch (error) {
			console.error('Unfiltered: processDiff error', error);
		}
	}

	async function processTarget(target, type = 'user') {
		const start = await getStartValue(revisionData);
		await fetchHistory(target, type, start);
		if (addedTitles.length) {
			await updateTitleLinks(addedTitles);
			addedTitles.length = 0;
		}
		if (contentChanges.size) {
			mutex.run(() => {
				mw.hook('wikipage.content').fire($([...contentChanges]));
				contentChanges.clear();
			});
		}
	}

	async function processRecent() {
		revisionData = new RecentData(context);
		if (!revisionData.firstRevid) return;
		try {
			if (context.type === 'wl') {
				for (const [page, timestamp] of revisionData.pages) {
					const start = offsetIso(timestamp, ABUSELOG_BUFFER);
					const end = offsetIso(timestamp, -86400);
					await fetchHistory(page, 'page', start, end);
				}
			} else {
				const timestamps = Object.values(revisionData.timestamps);
				if (!timestamps.length) return;
				timestamps.sort();
				const start = offsetIso(timestamps.at(-1), ABUSELOG_BUFFER);
				await fetchHistory(null, null, start);
			}
		} catch (error) {
			console.error('Unfiltered: processRecent error', error);
		}
	}

	async function getStartValue(revisionData) {
		if (!revisionData.hasNewer) {
			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 offsetIso(mwToIso(offsetParam), ABUSELOG_BUFFER);
		} else if (dirParam === 'prev') {
			const ts = await revisionData.getTimestamp(revisionData.firstRevid);
			if (ts) return offsetIso(ts, ABUSELOG_BUFFER);
		}
		return null;
	}

	async function fetchHistory(target, type, start, end) {
		const hits = {};
		const revisionMap = new Map();
		const warned = new Map();
		const generators = [];
		if (context.type === 'wl') {
			generators.push(cachedAbuseLogGenerator(target, type, start, end));
		} else {
			generators.push(abuseLogGenerator(target, type, start, end));
		}
		if (context.type === 'page' || context.type === 'user') {
			generators.push(
				logGenerator('spamblacklist', target, type, start),
				logGenerator('abusefilterblockeddomainhit', target, type, start)
			);
			if (!context.deleted && rights.deletedhistory) {
				generators.push(deletedRevisionsGenerator(target, type, start));
			}
		}
		let lastBatchId = null;
		for await (const entry of mergeGenerators(...generators)) {
			if (entry.revtype === 'deleted') {
				insertItem(createItem(entry));
				continue;
			}
			if (entry.batch_id !== lastBatchId) {
				await updateRevisions(hits);
				if (warned.size) updateWarned(warned);
			}
			lastBatchId = entry.batch_id;
			const revid = entry.revid;
			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';
			if (revid) {
				entry.revtype = revisionData.revElements[revid] ? 'matched' : 'unmatched';
			} else if ((context.type === 'page' || context.type === 'user') && !context.deleted) {
				const editKey = `${entry.timestamp}>${entry.title}>${entry.user}`;
				entry.norevid = revisionData.createNoRevid(editKey);
				entry.revtype = 'no-revision';
			}
			const key = entry.revid || entry.norevid;
			if (key) {
				hits[key] ??= [];
				hits[key].push(entry);
			}
		}
		await updateRevisions(hits, true);
		if (warned.size) updateWarned(warned);
	}

	async function* abuseLogGenerator(target, type, start, end) {
		const revisionCount = Object.keys(revisionData.revElements).length;
		let extra = revisionCount;
		if (context.type === 'diff' || context.type === 'rc' || context.type === 'wl') extra = 0;
		const limit = Math.min(50 + extra, 250);
		const excessLimit = revisionCount <= 50 ? 25 : 50;
		let excessCount = 0;
		const params = {
			action: 'query',
			list: 'abuselog',
			afllimit: limit,
			aflprop: 'ids|filter|user|title|action|result|timestamp|hidden|revid',
			format: 'json'
		};
		if (start) params.aflstart = start;
		if (end) params.aflend = end;
		if (type === 'user') params.afluser = target;
		else if (type === 'page') params.afltitle = target;
		let continueToken = null;
		do {
			const batchId = Math.random();
			const data = await api.get({ ...params, ...(continueToken || {}) });
			if (data?.error) {
				console.error('Unfiltered: abuseLogGenerator error', data.error);
				break;
			}
			for (const entry of data?.query?.abuselog || []) {
				const revid = entry.revid;
				if (revisionData.lastRevid) {
					if (revid) {
						if (Number(revid) < revisionData.lastRevid) excessCount++;
					} else {
						const lastTime = await revisionData.getTimestamp(revisionData.lastRevid);
						if (entry.timestamp < lastTime) excessCount++;
					}
				} else if (revisionData.hasOlder) {
					excessCount++;
				}
				entry.batch_id = batchId;
				yield entry;
			}
			if (excessCount >= excessLimit) break;
			continueToken = data?.continue;
		} while (continueToken);
	}

	async function* cachedAbuseLogGenerator(target, type, start, end) {
		cachedAbuseLogGenerator.cache ||= new Map();
		const cacheKey = `${target}-${type}-${start}-${end}`;
		const cached = cachedAbuseLogGenerator.cache.get(cacheKey);
		if (start && cached) {
			clearTimeout(cached.timer);
			cached.timer = setTimeout(() => cachedAbuseLogGenerator.cache.delete(cacheKey), 600000);
			yield* cached.entries;
			return;
		}
		const entries = await Array.fromAsync(abuseLogGenerator(target, type, start, end));
		yield* entries;
		const timer = setTimeout(() => cachedAbuseLogGenerator.cache.delete(cacheKey), 600000);
		cachedAbuseLogGenerator.cache.set(cacheKey, { entries, timer });
	}

	async function* logGenerator(letype, target, type, start) {
		const revisionCount = Object.keys(revisionData.revElements).length;
		const limit = Math.min(50 + revisionCount, 250);
		const excessLimit = revisionCount <= 50 ? 25 : 50;
		let excessCount = 0;
		// parsedcomment results are empty, query anyway in case it's fixed
		const params = {
			action: 'query',
			list: 'logevents',
			letype,
			lelimit: limit,
			leprop: 'ids|title|type|user|timestamp|parsedcomment|details',
			format: 'json'
		};
		if (start) params.lestart = start;
		if (type === 'user') params.leuser = target;
		else if (type === 'page') params.letitle = target;
		let continueToken = null;
		do {
			const batchId = Math.random();
			const data = await api.get({ ...params, ...(continueToken || {}) });
			if (data?.error) {
				console.error(`Unfiltered: logGenerator error (${letype})`, data.error);
				break;
			}
			for (const entry of data?.query?.logevents || []) {
				if (revisionData.lastRevid) {
					if (entry.revid) {
						if (Number(entry.revid) < revisionData.lastRevid) excessCount++;
					} else {
						const lastTime = await revisionData.getTimestamp(revisionData.lastRevid);
						if (entry.timestamp < lastTime) excessCount++;
					}
				} else if (revisionData.hasOlder) {
					excessCount++;
				}
				entry.batch_id = batchId;
				entry.result = 'disallow';
				entry.filter_id = entry.logid;
				entry.filter = strings[`log-name-${letype}`] || letype;
				if (letype === 'spamblacklist') {
					if (entry.params?.url) {
						const urls = entry.params.url.split(/\s+/).filter(u => u);
						try {
							const hostnames = [...new Set(urls.map(u => new URL(u).hostname))];
							if (hostnames.length === 1) {
								entry.filter_id = hostnames[0];
							}
						} catch (error) {
							console.warn('Unfiltered: invalid URL in log entry', error);
						}
						entry.filter += `: ${entry.params.url}`;
					}
				} else if (letype === 'abusefilterblockeddomainhit') {
					if (entry.params?.blocked) {
						entry.filter_id = entry.params.blocked;
						entry.filter += `: ${entry.params.blocked}`;
					}
				}
				yield entry;
			}
			if (excessCount >= excessLimit) break;
			continueToken = data?.continue;
		} while (continueToken);
	}

	async function* deletedRevisionsGenerator(target, type, start) {
		const params = {
			action: 'query',
			list: 'alldeletedrevisions',
			adrlimit: 50,
			adrprop: 'flags|ids|parsedcomment|size|tags|timestamp|user',
			format: 'json'
		};
		if (type === 'user') {
			if (start) params.adrstart = start;
			params.adruser = target;
		} else {
			params.adrfrom = params.adrto = mw.config.get('wgTitle');
			params.adrnamespace = mw.config.get('wgNamespaceNumber');
		}
		let continueToken = null;
		do {
			const batchId = Math.random();
			const data = await api.get({ ...params, ...(continueToken || {}) });
			if (data?.error) {
				console.error('Unfiltered: deletedRevisionsGenerator error', data.error);
				break;
			}
			for (const page of data?.query?.alldeletedrevisions || []) {
				for (const entry of page.revisions || []) {
					const { tooNew, tooOld } = await checkBounds(entry, 'deleted');
					if (tooOld) return;
					if (tooNew) continue;
					entry.title = page.title;
					entry.batchId = batchId;
					entry.revtype = 'deleted';
					yield entry;
				}
			}
			continueToken = data?.continue;
		} while (continueToken);
	}

	async function* mergeGenerators(...generators) {
		const promises = new Map();
		for (let i = 0; i < generators.length; i++) {
			promises.set(i, generators[i].next());
		}
		while (promises.size > 0) {
			const entries = Array.from(promises.entries());
			const result = await Promise.race(
				entries.map(async ([id, promise]) => ({ id, value: await promise }))
			);
			if (result.value.done) {
				promises.delete(result.id);
			} else {
				yield result.value.value;
				const gen = generators[result.id];
				promises.set(result.id, gen.next());
			}
		}
	}

	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 element = revisionData.revElements[revid] || revisionData.noRevElements[revid];
			if (!element && (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';
					element = createItem(first);
					insertItem(element);
				}
			}
			if (!element) continue;
			let container = element.querySelector('.abusefilter-container');
			if (!container) {
				container = document.createElement('span');
				container.className = 'abusefilter-container';
				element.append(' ', container);
			}
			let diff = element.querySelector('.mw-changeslist-links .unfiltered-diff-unresolved');
			for (const entry of hits[revid]) {
				if (container.firstChild) {
					container.prepend(' ');
				}
				container.prepend(createFilterElement(entry));
				if (diff && entry.id && entry.filter_id !== 'private') {
					resolveDiff(diff, entry.id);
					diff = null;
				}
			}
			matched.push(revid);
			// only fire for user pages to keep IP Information gadget from duplicating icons
			if (context.type === 'user') {
				contentChanges.add(element);
			}
		}
		for (const revid of matched) {
			delete hits[revid];
		}
	}

	function updateWarned(warned) {
		for (const [revid, filterText] of warned) {
			const element = revisionData.revElements[revid];
			if (!element) return;
			const filters = element.querySelectorAll('.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');
					warned.delete(revid);
					break;
				}
			}
		}
	}

	async function updateTitleLinks(links) {
		const titleToLinks = {};
		for (const link of links) {
			(titleToLinks[link.title] ||= []).push(link);
		}
		const existingPages = new Set(await getExistingPages(Object.keys(titleToLinks)));
		for (const [title, linkGroup] of Object.entries(titleToLinks)) {
			const isMissing = !existingPages.has(title);
			for (const link of linkGroup) {
				if (isMissing) {
					const url = new URL(link.href);
					if (url.pathname.startsWith(articlePath)) {
						url.search = `?title=${url.pathname.slice(articlePath.length)}&action=edit&redlink=1`;
						url.pathname = scriptPath;
						link.href = url.toString();
						link.classList.add('new');
						link.title = strings['red-link-title'].replace('$1', link.title);
					}
				} else {
					link.classList.remove('new');
				}
			}
		}
	}

	function insertItem(li) {
		return mutex.run(() => insertItemUnsafe(li));
	}

	async function insertItemUnsafe(li) {
		if (!firstInsert) {
			firstInsert = true;
			addToggleButton();
		}
		const allLis = Array.from(document.querySelectorAll('ul.mw-contributions-list > li:where([data-mw-revid],[data-revid],[data-norevid])'));
		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');
			if (entry.logid) {
				link.href = `${articlePath}${specialName}:${strings.log}?logid=${entry.logid}`;
			} else {
				link.href = `${articlePath}${specialName}:${strings.abuselog}/${entry.id}`;
			}
			link.textContent = entry.filter_id;
			element.append(link);
		} else {
			element.textContent = strings['abusefilter-hidden'];
		}
		return element;
	}

	function resolveDiff(diffSpan, logid) {
		const link = document.createElement('a');
		link.textContent = diffSpan.textContent;
		link.href = `${articlePath}${specialName}:${strings.abuselog}/${logid}`;
		link.className = 'unfiltered-diff';
		link.dataset.unfilteredLogId = logid;
		diffSpan.replaceChildren(link);
		diffSpan.classList.remove('unfiltered-diff-unresolved');
	}

	function createItem(entry) {
		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);
		if (context.type === 'page') {
			li.append(createDiffLinks(entry, pageTitleEncoded), ' ', createSeparator(), ' ');
		}
		const formattedTimestamp = formatTimeAndDate(new Date(entry.timestamp));
		let timestamp;
		if (entry.revtype === 'deleted' && rights.deletedtext) {
			const ts = new Date(entry.timestamp).toISOString().replace(/\D/g, '').slice(0, 14);
			timestamp = document.createElement('a');
			timestamp.className = 'mw-changeslist-date';
			timestamp.href = `${scriptPath}?title=${specialName}:${strings.undelete}&target=${pageTitleEncoded}&timestamp=${ts}`;
			timestamp.title = `${specialName}:${strings.undelete}`;
			timestamp.textContent = formattedTimestamp;
		} else {
			timestamp = document.createElement('span');
			timestamp.className = 'mw-changeslist-date';
			timestamp.textContent = formattedTimestamp;
		}
		li.append(timestamp, ' ');
		if (includeFields.has('title') && entry.title) {
			li.append(createDiffLinks(entry, pageTitleEncoded), ' ');
		}
		let titleSpanWrapper;
		if (includeFields.has('title') && entry.title) {
			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;
			titleLink.href = `${articlePath}${pageTitleEncoded}`;
			if (entry.revtype === 'deleted') {
				titleLink.className = 'mw-contributions-title new';
			} else {
				titleLink.className = 'mw-contributions-title';
			}
			titleLink.title = entry.title;
			titleBdi.append(titleLink);
			titleSpanWrapper.append(titleBdi);
			addedTitles.push(titleLink);
		}
		const label = document.createElement('span');
		label.textContent = strings['revision-' + entry.revtype] || entry.revtype;
		label.style.fontStyle = 'italic';
		li.append(createSeparator(), ' ', label, ' ', createSeparator(), ' ');
		if (entry.revtype === 'deleted') {
			if (entry.minor !== undefined) {
				const minorAbbr = document.createElement('abbr');
				minorAbbr.className = 'minoredit';
				minorAbbr.title = strings['recentchanges-label-minor'];
				minorAbbr.textContent = strings.minoreditletter;
				li.append(' ', minorAbbr, ' ');
			}
			if (entry.parentid === 0) {
				const newAbbr = document.createElement('abbr');
				newAbbr.className = 'newpage';
				newAbbr.title = strings.newpage;
				newAbbr.textContent = strings.newpageletter;
				li.append(' ', newAbbr, ' ');
			}
		}
		if (titleSpanWrapper) {
			li.append(titleSpanWrapper, ' ', createSeparator(), ' ');
		}
		if (includeFields.has('user') && entry.user) {
			li.append(createUserNodes(entry));
		}
		if (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;
	}

	function createDiffLinks(entry, pageTitleEncoded) {
		const linksSpan = document.createElement('span');
		linksSpan.className = 'mw-changeslist-links';
		const diffSpan = document.createElement('span');
		diffSpan.className = 'unfiltered-diff-unresolved';
		if (context.type === 'user') {
			diffSpan.textContent = strings.diff;
			const historySpan = document.createElement('span');
			const historyLink = document.createElement('a');
			if (entry.revtype === 'deleted') {
				historyLink.href = `${scriptPath}?title=${specialName}:${strings.undelete}&target=${pageTitleEncoded}`;
			} else {
				historyLink.href = `${articlePath}${pageTitleEncoded}?action=history`;
			}
			historyLink.textContent = strings.hist;
			historyLink.className = 'mw-changeslist-history';
			historySpan.append(historyLink);
			linksSpan.append(diffSpan, ' ', historySpan);
		} else {
			linksSpan.classList.add('mw-history-histlinks');
			diffSpan.textContent = strings.last;
			const curSpan = document.createElement('span');
			curSpan.textContent = strings.cur;
			linksSpan.append(curSpan, ' ', diffSpan);
		}
		return linksSpan;
	}

	function createSeparator() {
		const span = document.createElement('span');
		span.className = 'mw-changeslist-separator';
		return span;
	}

	function createUserNodes(entry) {
		let nodes;
		if (context.type === 'user') {
			nodes = document.createDocumentFragment();
		} else {
			nodes = document.createElement('span');
			nodes.className = 'history-user';
		}
		const isAnon = mw.util.isIPAddress(entry.user, true);
		const userEncoded = mw.util.wikiUrlencode(entry.user);
		const userNamespace = mw.config.get('wgFormattedNamespaces')?.[2] || 'User';
		const userLink = document.createElement('a');
		userLink.className = isAnon ? 'mw-userlink mw-anonuserlink' : 'mw-userlink';
		if (isAnon) {
			userLink.href = `${articlePath}${specialName}:${strings.contributions}/${userEncoded}`;
		} else {
			userLink.href = `${articlePath}${userNamespace}:${userEncoded}`;
			userLink.title = `${userNamespace}:${entry.user}`;
			addedTitles.push(userLink);
		}
		if (entry.revid) userLink.setAttribute('data-mw-revid', entry.revid);
		const bdi = document.createElement('bdi');
		bdi.textContent = entry.user;
		userLink.append(bdi);
		if (context.type === 'user') {
			const wrapper = document.createElement('bdi');
			wrapper.setAttribute('dir', 'ltr');
			wrapper.append(userLink);
			nodes.append(wrapper);
		} else {
			nodes.append(userLink);
		}
		const toolsSpan = document.createElement('span');
		toolsSpan.className = 'mw-usertoollinks mw-changeslist-links';
		const talkSpan = document.createElement('span');
		const talkLink = document.createElement('a');
		const userTalkNamespace = mw.config.get('wgFormattedNamespaces')?.[3] || 'User talk';
		const encodedTalk = mw.util.wikiUrlencode(`${userTalkNamespace}:${entry.user}`);
		talkLink.href = `${articlePath}${encodedTalk}`;
		talkLink.className = 'mw-usertoollinks-talk';
		talkLink.title = `${userTalkNamespace}:${entry.user}`;
		talkLink.textContent = 'talk';
		addedTitles.push(talkLink);
		talkSpan.append(talkLink);
		toolsSpan.append(talkSpan);
		if (!isAnon) {
			const contribsSpan = document.createElement('span');
			const contribsLink = document.createElement('a');
			contribsLink.href = `${articlePath}${specialName}:${strings.contributions}/${userEncoded}`;
			contribsLink.className = 'mw-usertoollinks-contribs';
			contribsLink.title = `${specialName}:${strings.contributions}/${entry.user}`;
			contribsLink.textContent = 'contribs';
			contribsSpan.append(contribsLink);
			toolsSpan.append(contribsSpan);
		}
		if (context.type === 'page' && rights.block) {
			const blockSpan = document.createElement('span');
			const blockLink = document.createElement('a');
			blockLink.href = `${articlePath}${specialName}:Block/${userEncoded}`;
			blockLink.className = 'mw-usertoollinks-block';
			blockLink.title = `${specialName}:Block/${entry.user}`;
			blockLink.textContent = 'block';
			blockSpan.append(blockLink);
			toolsSpan.append(blockSpan);
		}
		nodes.append(' ', toolsSpan);
		return nodes;
	}

	function offsetIso(iso, seconds = 0) {
		const date = new Date(iso);
		date.setSeconds(date.getSeconds() + seconds);
		if (seconds && date > new Date()) return null;
		return date.toISOString();
	}

	function mwToIso(t) {
		if (!t || t.length !== 14) return null;
		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`;
	}
});