Jump to content

User:Daniel Quinlan/Scripts/Blame.js

From Wikipedia, the free encyclopedia
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.storage', 'mediawiki.user', 'mediawiki.util', 'mediawiki.DateFormatter', 'oojs-ui']).then(function () {
	const namespace = mw.config.get('wgNamespaceNumber');
	const action = mw.config.get('wgAction');
	const special = mw.config.get('wgCanonicalSpecialPageName');
	const page = mw.config.get('wgPageName');
	let target;

	if (namespace >= 0 && ['edit', 'history', 'view'].includes(action)) {
		target = page;
	} else if (special === 'AbuseFilter') {
		const parts = page.split('/').slice(1);
		if (parts[0] === 'history') parts.shift();
		if (/^\d+$/.test(parts[0])) target = parts[0];
	}
	if (!target) return;

	const api = new mw.Api();
	const scriptPath = mw.config.get('wgScript');
	const articlePath = mw.config.get('wgArticlePath');
	const portletId = ['minerva', 'monobook'].includes(mw.config.get('skin')) ? 'p-tb' : 'p-cactions';
	const dateFormat = mw.user.options.get('date')?.toLowerCase().replace(/\s/g, '') || 'none';
	const userSpace = mw.config.get('wgFormattedNamespaces')[2];
	let windowManager = null;

	mw.util.addPortletLink(portletId, '#', 'Blame', 't-blame', 'Find changes in history')
		.addEventListener('click', e => {
			e.preventDefault();
			openDialog();
		});

	function addStyles() {
		mw.util.addCSS(`
			.blame-dialog-frame { height: 90vh !important; }
			.blame-flags { display: flex; width: 150px; }
			.blame-flags .oo-ui-optionWidget { display: flex; flex: 1; }
			.blame-flags .oo-ui-buttonElement-button { width: 100%; }
			.blame-row:not(:last-child) { margin-bottom: 0.5em; }
			.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-success .oo-ui-inputWidget-input {
				border-color: var(--border-color-success, #229679);
			}
			.blame-message { margin-bottom: 0.5em; }
			.blame-message-error .blame-message-status { color: var(--color-error, #de5a49); }
			.blame-message-success .blame-message-status { color: var(--color-success, #229679); }
			.blame-message-warning .blame-message-status { color: var(--color-warning, #a97e2a); }
			.blame-message-diff::before { content: '('; }
			.blame-message-diff::after { content: ')'; }
			.blame-results-table {
				table-layout: fixed;
				border-collapse: separate;
				border-spacing: 0 5px;
			}
			.blame-results-revision { text-align: right; padding-right: 1em; }
			.blame-results-timestamp, .blame-results-diff, .blame-results-user {
				text-align: left;
				padding-right: 1em;
			}
			.blame-results-timestamp-iso8601 { padding-right: 0.25em; }
			.blame-results-diff::before { content: '('; }
			.blame-results-diff::after { content: ')'; }
			.blame-results-user {
				max-width: 20ch;
				white-space: nowrap;
				overflow: hidden;
				text-overflow: ellipsis;
			}
			.blame-results-status { text-align: left; }
			.blame-results-status[data-result="true"]::before {
				content: '✓ ';
				color: var(--color-success, #229679);
			}
			.blame-results-status[data-result="false"]::before {
				content: '✕ ';
				color: var(--color-error, #de5a49);
			}
		`);
	}

	class BlameDialog extends OO.ui.ProcessDialog {
		static static = {
			name: 'blame-dialog',
			title: special === 'AbuseFilter' ? 'Filter blame' : 'Page blame',
			actions: [
				{ action: 'search', label: 'Search', flags: ['primary', 'progressive'] },
				{ action: 'cancel', label: 'Cancel', flags: ['safe', 'close'] }
			]
		};

		initialize() {
			super.initialize();
			// helpers
			const createTextInput = (placeholder, validate) =>
				new OO.ui.TextInputWidget({ placeholder, validate });
			const createButton = options =>
				new OO.ui.ButtonOptionWidget({ data: options.label?.toLowerCase(), ...options });
			const createSelectInput = (...items) =>
				new OO.ui.ButtonSelectWidget({
					items: items.map(createButton),
					multiselect: !items.some(i => i.selected)
				});
			const createLayout = (item, label, help) =>
				new OO.ui.FieldLayout(item, { align: 'top', label, help });
			const createRow = (...items) =>
				new OO.ui.HorizontalLayout({
					items: items.map(i => createLayout(...i)),
					classes: ['blame-row']
				});
			const validSearch = new RegExp(/./);
			const validCount = new RegExp(/^\d*$/);
			const validBound = new RegExp(/^\d*$|^\d{4}-\d\d-\d\d([\sT]\d\d:\d\d(:\d\d)?)?$/);
			const updateFlags = item => {
				const isRegex = item.getData() === 'regex';
				const flags = this.options.flags;
				flags.findItemFromData('i').setLabel(isRegex ? 'i' : 'Case insensitive');
				if (isRegex) {
					flags.addItems(this.regexItems);
					this.previousFlags?.forEach(f => flags.selectItem(f));
					this.previousFlags = null;
				} else {
					this.previousFlags = flags.findSelectedItems()?.filter(f => f.data !== 'i');
					// workaround: removeItems deselects all when removing selected items
					this.previousFlags?.forEach(f => flags.unselectItem(f));
					flags.removeItems(this.regexItems);
				}
			};
			// options
			this.highlightedOptions = new Set();
			this.options = {
				searchText: createTextInput('Text or regular expression', validSearch),
				matchType: createSelectInput(
					{ label: 'String', title: 'Exact text match', selected: true },
					{ label: 'Regex', title: 'Regular expression match' }
				),
				flags: createSelectInput(
					{ data: 'i', label: 'Case insensitive', title: 'Match regardless of case' }
				),
				method: createSelectInput(
					{ label: 'Binary', title: 'Faster binary search', selected: true },
					{ label: 'Linear', title: 'Slower chronological search' },
					{ label: 'Complete', title: 'Slowest exhaustive search' }
				),
				changeType: createSelectInput(
					{ label: 'Addition', title: 'Find the revision where text was added', selected: true },
					{ label: 'Removal', title: 'Find the revision where text was removed' }
				),
				maxCount: createTextInput('Count (optional)', validCount),
				skipCount: createTextInput('Count (optional)', validCount),
				start: createTextInput('Revision or date (optional)', validBound),
				end: createTextInput('Revision or date (optional)', validBound),
			};
			this.regexItems = [
				{ label: 'm', title: 'Allow ^ and $ to match on each line' },
				{ label: 's', title: 'Allow . to match newlines' }
			].map(createButton);
			// classes
			this.options.flags.$element.addClass('blame-flags');
			// results
			this.resultsOutput = new OO.ui.PanelLayout({ padded: false, expanded: false });
			// fieldsets
			const boundHelp = new OO.ui.HtmlSnippet('Enter a revision ID (number) or date in <code>YYYY-MM-DD</code> format. Time can be added as <code>YYYY-MM-DDTHH:MM:SS</code> or <code>YYYY-MM-DD HH:MM</code>. Dates and times are interpreted using your timezone preference to match displayed timestamps.');
			const maxCountHelp = 'Limits the number of revisions searched. Any start or end filtering is applied first.';
			const skipCountHelp = 'Number of most recent revisions to skip. This filter is applied last.';
			const fieldsets = {
				'Search': [createLayout(this.options.searchText)],
				'Matching': [createRow([this.options.matchType], [this.options.flags])],
				'Search method': [createLayout(this.options.method)],
				'Change type': [createLayout(this.options.changeType)],
				'Revisions': [
					createRow(
						[this.options.maxCount, 'Maximum revisions', maxCountHelp],
						[this.options.skipCount, 'Skip revisions', skipCountHelp]
					),
					createRow(
						[this.options.start, 'Start (newest)', boundHelp],
						[this.options.end, 'End (oldest)', boundHelp]
					)
				],
				'Results': [this.resultsOutput]
			};
			// populate panel
			this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });
			for (const [label, fields] of Object.entries(fieldsets)) {
				const fieldset = new OO.ui.FieldsetLayout({ label, align: 'top' });
				fieldset.addItems(fields);
				this.content.$element.append(fieldset.$element);
				if (label === 'Results') {
					this.resultsFieldset = fieldset;
					this.resultsFieldset.toggle(false);
				}
			}
			// defaults
			const highlightOption = field => {
				this.highlightedOptions.add(field);
				field.setFlags({ success: true });
				field.once('change', () => field.setFlags({ success: false }));
			};
			const selectedText = window.getSelection().toString();
			if (selectedText) {
				this.options.searchText.setValue(selectedText);
				highlightOption(this.options.searchText);
			}
			const revisionId = mw.config.get('wgRevisionId');
			if (revisionId && revisionId !== mw.config.get('wgCurRevisionId')) {
				this.options.start.setValue(revisionId);
				highlightOption(this.options.start);
			}
			// event handling
			for (const item of Object.values(this.options)) {
				if (item instanceof OO.ui.TextInputWidget) {
					item.on('enter', () => { this.executeAction('search'); });
				}
			}
			this.options.matchType.on('select', item => updateFlags(item));
			this.options.method.on('select', item => {
				const method = item?.getData();
				if (method !== 'binary' && !this.options.maxCount.getValue().trim()) {
					this.options.maxCount.setValue('500');
					highlightOption(this.options.maxCount);
				}
				const isComplete = method === 'complete';
				const changeType = this.options.changeType;
				const selected = changeType.findSelectedItem?.()?.getData();
				if (isComplete) {
					if (!this.previousChangeType) this.previousChangeType = selected;
					changeType.selectItem();
				} else if (this.previousChangeType) {
					changeType.selectItemByData(this.previousChangeType);
					this.previousChangeType = null;
				}
				changeType.findItemFromData('addition').setDisabled(isComplete);
				changeType.findItemFromData('removal').setDisabled(isComplete);
			});
			// finish
			this.$body.append(this.content.$element);
		}

		getActionProcess(action) {
			if (action === 'search') {
				this.highlightedOptions.forEach(field => field.setFlags({ success: false }));
				return new OO.ui.Process(() => this.startSearch());
			}
			if (action === 'cancel') {
				return new OO.ui.Process(() => this.close({ action: 'cancel' }));
			}
			return super.getActionProcess(action);
		}

		async startSearch() {
			this.resultsFieldset.toggle(true);
			this.blame.messages = new MessageHandler(this.resultsOutput);
			let searchParams;
			try {
				searchParams = this.getValidatedParams();
			} catch (e) {
				this.blame.messages.error(e.message);
				return;
			}
			await this.blame.search(searchParams);
		}

		getValidatedParams() {
			const getOption = item => {
				if (item.getValue) return item.getValue();
				const items = item.findSelectedItems();
				return Array.isArray(items) ? items.map(i => i.getData()) : items?.getData();
			};
			const params = Object.fromEntries(
				Object.entries(this.options).map(([key, item]) => [key, getOption(item)])
			);
			params.matcher = this.buildMatcher(params);
			params.maxCount = this.parseNumber(params.maxCount, 'Maximum');
			params.skipCount = this.parseNumber(params.skipCount, 'Skip');
			params.start = this.parseBound(params.start, 'Start');
			params.end = this.parseBound(params.end, 'End');
			return params;
		}

		buildMatcher(searchParams) {
			const { searchText, matchType, flags, changeType } = searchParams;
			const flagsString = flags ? flags.join('') : '';
			if (!searchText) {
				throw new Error('Please enter a search term.');
			}
			let matcher;
			if (matchType === 'string') {
				if (flagsString.includes('i')) {
					const lower = searchText.toLowerCase();
					matcher = text => text.toLowerCase().includes(lower);
				} else {
					matcher = text => text.includes(searchText);
				}
			} else {
				try {
					const regex = new RegExp(searchText, flagsString + 'u');
					matcher = text => regex.test(text);
				} catch (e) {
					throw new Error(`Invalid regex pattern: ${e.message}`);
				}
			}
			if (changeType === 'removal') {
				return text => !matcher(text);
			}
			return matcher;
		}

		parseNumber(input, field) {
			const trimmed = input.trim();
			if (!trimmed) return null;
			if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10);
			throw new Error(`${field} must be a valid number.`);
		}

		parseBound(input, field) {
			try {
				const trimmed = input.trim();
				if (!trimmed) return null;
				if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10);
				const tz = this.getTimezone();
				let dateString = trimmed;
				if (!dateString.includes(':')) {
					dateString += field === 'Start' ? 'T23:59:59' : 'T00:00:00';
				}
				const date = new Date(dateString + tz);
				if (isNaN(date.getTime())) throw new Error('invalid date');
				return date;
			} catch (e) {
				throw new Error(`${field} must be a valid revision ID or date: ${e.message}`);
			}
		}

		getTimezone() {
			const correction = mw.user.options.get('timecorrection') || '';
			const match = correction.match(/^(?:Offset|System|ZoneInfo)\|(-?\d+)(?:\||$)/);
			const minutes = match ? Number(match[1]) : -new Date().getTimezoneOffset();
			const sign = minutes >= 0 ? '+' : '-';
			const abs = Math.abs(minutes);
			const pad = n => String(n).padStart(2, '0');
			return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
		}
	}

	function openDialog() {
		if (!windowManager) {
			addStyles();
			windowManager = new OO.ui.WindowManager();
			document.body.append(windowManager.$element[0]);
		}
		const dialog = new BlameDialog({ size: 'large', classes: ['blame-dialog'] });
		dialog.blame = special === 'AbuseFilter'
			? new AbuseFilterBlame(target)
			: new PageBlame(target);
		windowManager.addWindows([dialog]);
		const windowPromise = windowManager.openWindow(dialog);
		windowPromise.opened.then(() => {
			dialog.options.searchText.focus();
		});
		windowPromise.closed.then(() => {
			windowManager.removeWindows([BlameDialog.static.name]);
			if (dialog.blame) dialog.blame.abortSearch = true;
		});
		dialog.$element.find('.oo-ui-window-frame').addClass('blame-dialog-frame');
	}

	class IndirectMap extends Map {
		constructor() {
			super();
			this.index = new Map();
		}

		bind(key, id) {
			this.index.set(key, id);
		}

		set(key, value) {
			const id = this.index.get(key);
			if (id) super.set(id, value);
		}

		get(key) {
			const id = this.index.get(key);
			return id ? super.get(id) : undefined;
		}

		has(key) {
			const id = this.index.get(key);
			return id ? super.has(id) : false;
		}
	}

	class Blame {
		constructor(target) {
			this.target = target;
			this.userDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
		}

		async match(versionId, searchParams, ordering = null) {
			const text = await this.fetchContent(versionId, searchParams, ordering);
			if (text === null) {
				this.messages.error('Revision content unavailable: ', this.createLinks(versionId));
				this.matchResults.set(versionId, false);
				if (searchParams.method !== 'complete') {
					this.abortSearch = true;
				}
				return false;
			}
			try {
				const result = searchParams.matcher(text);
				this.matchResults.set(versionId, result);
				return result;
			} catch (e) {
				this.messages.error(`Matching error: ${e.message}`);
				this.matchResults.set(versionId, false);
				this.abortSearch = true;
				return false;
			}
		}

		async linearSearch(searchParams) {
			for (let i = 0; i < this.versions.length - 1; i++) {
				if (this.abortSearch) return;
				const newerMatch = await this.match(this.versions[i], searchParams);
				const olderMatch = await this.match(this.versions[i + 1], searchParams);
				if (newerMatch && !olderMatch) {
					this.messages.clear();
					this.messages.success('Found change: ', this.createLinks(this.versions[i]));
					return;
				}
			}
			this.messages.clear();
			this.messages.warning('Search completed, but no revision found where the search result changed.');
		}

		async completeSearch(searchParams) {
			let matches = 0;
			for (let i = 0; i < this.versions.length; i++) {
				if (this.abortSearch) return;
				if (await this.match(this.versions[i], searchParams)) matches++;
			}
			this.messages.clear();
			const fragment = document.createDocumentFragment();
			fragment.append(`${matches}/${this.versions.length} matched`);
			this.messages.success('Search completed: ', fragment);
		}

		interleaveArray(array, max) {
			const length = array.length;
			if (length <= 1) return array.slice();
			const result = [];
			const visited = new Array(length).fill(false);
			const addIndex = (i) => {
				if (!visited[i]) {
					visited[i] = true;
					result.push(array[i]);
				}
			};
			addIndex(0);
			addIndex(length - 1);
			let m = 1;
			const limit = (max == null ? length : Math.min(max, length));
			while (result.length < limit) {
				const divisor = 1 << m;
				for (let k = 1; k < divisor; k += 2) {
					addIndex(Math.floor((k / divisor) * (length - 1)));
					if (result.length >= limit) break;
				}
				m++;
			}
			return result;
		}

		async binarySearch(searchParams) {
			let left = 0; // newest version (descending order)
			let right = this.versions.length - 1; // oldest version
			const newestVal = await this.match(this.versions[left], searchParams);
			const oldestVal = await this.match(this.versions[right], searchParams);
			if (newestVal === oldestVal) {
				this.messages.notice('Newest and oldest revisions match. Probing intermediate revisions (may take a while) to locate a differing revision...');
				const interleaved = this.interleaveArray(this.versions);
				let found = false;
				// start at 1 to skip newest, already tested
				for (let i = 1; i < interleaved.length; i++) {
					if (this.abortSearch) return;
					const val = await this.match(interleaved[i], searchParams, interleaved);
					if (val !== newestVal) {
						right = this.versions.indexOf(interleaved[i]);
						found = true;
						break;
					}
				}
				if (!found) {
					this.messages.clear();
					this.messages.warning('Search completed, but no revision found where the search result changed.');
					return;
				}
			}
			while (left < right) {
				if (this.abortSearch) return;
				const mid = Math.floor((left + right) / 2);
				const midVal = await this.match(this.versions[mid], searchParams);
				if (midVal) {
					left = mid + 1;	// match: keep searching older
				} else {
					right = mid; // no match: go newer
				}
			}
			const matchIndex = left;
			const matchVersion = this.versions[matchIndex];
			const nextVersion = this.versions[Math.max(matchIndex - 1, 0)];
			const matchText = await this.match(matchVersion, searchParams);
			const nextMatch = await this.match(nextVersion, searchParams);
			const changed = !matchText && nextMatch;
			if (changed) {
				this.messages.clear();
				this.messages.success('Found change: ', this.createLinks(nextVersion));
			} else {
				this.messages.error('Unable to find the revision where the search result changed.');
			}
		}

		async search(searchParams) {
			const { method, changeType } = searchParams;
			this.startTime = performance.now();
			this.abortSearch = false;
			this.matchResults = new Map();
			this.messages.notice('Fetching revision list...');
			await this.fetchVersions(searchParams);
			if (method !== 'complete' && this.versions.length <= 1) {
				this.messages.warning(`Only ${this.versions.length} revision${this.versions.length !== 1 ? 's' : ''} found. Cannot search.`);
				return;
			}
			this.messages.notice(`Found ${this.versions.length} revision${this.versions.length !== 1 ? 's' : ''}. Searching...`);
			if (method !== 'complete' && !await this.match(this.versions[0], searchParams)) {
				this.messages.clear();
				this.messages.warning(`Newest revision tested ${changeType === 'removal' ? 'contains' : 'missing'} text: `, this.createLinks(this.versions[0]));
				return;
			}
			if (method === 'linear') await this.linearSearch(searchParams);
			else if (method === 'complete') await this.completeSearch(searchParams);
			else await this.binarySearch(searchParams);
			this.addResults();
		}

		createLinks(versionId, table = false) {
			const createLink = (href, textContent) =>
				Object.assign(document.createElement('a'), { href, target: '_blank', textContent });
			const links = this.resultLinks(versionId);
			const timestamp = this.versionData.get(versionId)?.timestamp;
			const ts = timestamp ? this.userDate(new Date(timestamp)) : undefined;
			const user = this.versionData.get(versionId)?.user;
			let userNode;
			if (user) {
				userNode = createLink(articlePath.replace('$1', `${userSpace}:${encodeURIComponent(user)}`), user);
			} else {
				userNode = document.createTextNode('unknown');
			}
			const diff = document.createElement('span');
			diff.append(createLink(links.diff, 'diff'));
			if (table) {
				return {
					revision: createLink(links.revision, versionId),
					timestamp: createLink(links.revision, ts ?? 'unknown'),
					diff,
					user: userNode
				};
			}
			const fragment = document.createDocumentFragment();
			const text = ts ? `${versionId} \u00b7 ${ts}` : `revision ${versionId}`;
			diff.className = 'blame-message-diff';
			fragment.append(createLink(links.revision, text), ' ', diff, ' \u00b7 ', userNode);
			return fragment;
		}

		addResults() {
			if (this.matchResults.size) {
				const elapsed = this.startTime ? ((performance.now() - this.startTime) / 1000).toFixed(2) : 'unknown';
				this.messages.notice(`Searched ${this.matchResults.size}/${this.versions.length} revisions in ${elapsed} seconds.`);
			}
			const revids = Array.from(this.matchResults.keys()).sort((a, b) => b - a);
			const table = document.createElement('table');
			const tsClass = `blame-results-timestamp-${dateFormat}`;
			table.className = 'blame-results-table';
			revids.forEach(revid => {
				const row = table.insertRow();
				const cells = this.createLinks(revid, true);
				cells.status = this.matchResults.get(revid).toString();
				Object.entries(cells).forEach(([key, value]) => {
					const cell = row.insertCell();
					cell.append(value);
					cell.className = `blame-results-${key}`;
					if (key === 'timestamp') cell.classList.add(tsClass);
					if (key === 'status') cell.setAttribute('data-result', value);
				});
			});
			this.messages.notice(table);
		}
	}

	class AbuseFilterBlame extends Blame {
		static versionCache = new Map();

		constructor(target) {
			super(target);
			this.aliasesPromise = AbuseFilterBlame.fetchAliases();
			this.primaryAlias = null;
		}

		static async* logEvents(limit = 'max', letitle, start, end) {
			const baseParams = {
				action: 'query',
				format: 'json',
				list: 'logevents',
				letype: 'abusefilter',
				lelimit: limit
			};
			if (letitle) baseParams.letitle = letitle;
			if (start instanceof Date) baseParams.lestart = start.toISOString();
			if (end instanceof Date) baseParams.leend = end.toISOString();
			let offset;
			while (true) {
				const params = { ...baseParams, ...(offset != null && { lecontinue: offset }) };
				const result = await api.get(params);
				const events = result.query?.logevents || [];
				for (const ev of events) {
					yield ev;
				}
				if (!result.continue?.lecontinue) break;
				offset = result.continue.lecontinue;
			}
		}

		static async fetchAliases() {
			const siteId = mw.config.get('wgWikiID') || 'unknown';
			if (['commonswiki', 'enwiki', 'metawiki', 'simplewiki', 'wikidatawiki'].includes(siteId)) {
				return ['Special:AbuseFilter'];
			}
			const aliasesKey = `blame-aliases-${siteId}`;
			const cached = mw.storage.getObject(aliasesKey);
			if (cached) return cached;
			const aliasSet = new Set();
			for await (const ev of this.logEvents()) {
				if (!ev.title) continue;
				const alias = ev.title.split('/')[0];
				if (alias) aliasSet.add(alias);
			}
			const aliasArray = Array.from(aliasSet);
			mw.storage.setObject(aliasesKey, aliasArray, 604800);
			return aliasArray;
		}

		async fetchVersions(searchParams) {
			const { start, end, maxCount, skipCount } = searchParams;
			const versions = [];
			const versionData = new Map();
			const limit = (maxCount && maxCount <= 500) ? maxCount : 'max';
			const aliases = await this.aliasesPromise;
			if (!this.primaryAlias) {
				this.primaryAlias = aliases[0] || 'Special:AbuseFilter';
			}
			for (const alias of aliases) {
				if (this.abortSearch) return;
				const letitle = `${alias}/${this.target}`;
				for await (const ev of AbuseFilterBlame.logEvents(limit, letitle, start, end)) {
					const id = ev.params?.historyId ?? (ev.params?.[0] ? parseInt(ev.params[0], 10) : null);
					if (id == null || !ev.timestamp) continue;
					if (typeof start === 'number' && id > start) continue;
					if (typeof end === 'number' && id < end) break;
					versions.push(id);
					versionData.set(id, { timestamp: ev.timestamp, user: ev.user });
				}
			}
			if (aliases.length > 1)
				versions.sort((a, b) => b - a);
			if (maxCount && versions.length > maxCount)
				versions.length = maxCount;
			if (skipCount)
				versions.splice(0, skipCount);
			this.versions = versions;
			this.versionData = versionData;
		}

		async fetchContent(versionId, searchParams, ordering = null) {
			if (AbuseFilterBlame.versionCache.has(versionId)) {
				return AbuseFilterBlame.versionCache.get(versionId);
			}
			const link = articlePath.replace('$1', `${this.primaryAlias}/history/${this.target}/item/${versionId}`);
			try {
				const html = await fetch(link, { cache: 'force-cache' }).then(r => {
					if (!r.ok) throw new Error(`HTTP error: ${r.status} ${r.statusText}`);
					return r.text();
				});
				const tree = new DOMParser().parseFromString(html, 'text/html');
				const content = tree.querySelector('#wpFilterRules')?.value ?? null;
				if (content !== null) {
					AbuseFilterBlame.versionCache.set(versionId, content);
				}
				return content;
			} catch (e) {
				console.warn(`Failed to fetch revision ${versionId}: ${e.message}`);
				return null;
			}
		}

		resultLinks(versionId) {
			return {
				revision: articlePath.replace('$1', `${this.primaryAlias}/history/${this.target}/item/${versionId}`),
				diff: articlePath.replace('$1', `${this.primaryAlias}/history/${this.target}/diff/prev/${versionId}`)
			};
		}
	}

	class PageBlame extends Blame {
		static versionCache = new IndirectMap();
		static MAX_BATCH_SIZE = 1024 * 1024;

		async fetchVersions(searchParams) {
			const { start, end, maxCount, skipCount } = searchParams;
			const versions = [];
			const versionData = new Map();
			const limit = (maxCount && maxCount <= 500) ? maxCount : 'max';
			let offset;
			const baseParams = {
				action: 'query',
				format: 'json',
				prop: 'revisions',
				titles: this.target,
				rvlimit: limit,
				rvprop: 'ids|sha1|size|timestamp|user'
			};
			if (start instanceof Date) {
				baseParams.rvstart = start.toISOString();
			} else if (typeof start === 'number') {
				baseParams.rvstartid = start;
			}
			if (end instanceof Date) {
				baseParams.rvend = end.toISOString();
			} else if (typeof end === 'number') {
				baseParams.rvendid = end;
			}
			outer: while (true) {
				if (this.abortSearch) return;
				const params = { ...baseParams, ...(offset && { rvcontinue: offset }) };
				const result = await api.get(params);
				const pageObj = Object.values(result.query?.pages || {})[0];
				if (!pageObj?.revisions) break;
				for (const rev of pageObj.revisions) {
					if (rev.sha1hidden || rev.suppressed) continue;
					if (rev.revid == null || !rev.sha1 || rev.size == null || !rev.timestamp) continue;
					versions.push(rev.revid);
					PageBlame.versionCache.bind(rev.revid, rev.sha1);
					versionData.set(rev.revid, {
						size: rev.size,
						timestamp: rev.timestamp,
						user: rev.user
					});
					if (maxCount && versions.length >= maxCount) break outer;
				}
				if (!result.continue?.rvcontinue) break;
				offset = result.continue.rvcontinue;
			}
			if (skipCount) {
				versions.splice(0, skipCount);
			}
			this.versions = versions;
			this.versionData = versionData;
		}

		batchRevisions(versions) {
			let totalSize = 1024;
			const revids = [];
			for (const version of versions) {
				const data = this.versionData.get(version);
				if (!data || PageBlame.versionCache.has(version)) continue;
				const size = Math.ceil(data.size * 1.1) + 1024;
				if (totalSize + size > PageBlame.MAX_BATCH_SIZE) break;
				totalSize += size;
				revids.push(version);
				if (revids.length >= 50) break;
			}
			return revids;
		}

		async fetchContent(versionId, searchParams, ordering = null) {
			if (PageBlame.versionCache.has(versionId)) {
				return PageBlame.versionCache.get(versionId);
			}
			const attempts = [String(versionId)];
			if (searchParams.method !== 'binary' || ordering) {
				const sequence = ordering ? ordering : this.versions;
				const index = sequence.indexOf(versionId);
				if (index >= 0) {
					const bulk = this.batchRevisions(sequence.slice(index));
					if (bulk.length) attempts.unshift(bulk.join('|'));
				}
			} else if (searchParams.method === 'binary' && versionId === this.versions[0]) {
				const interleaved = this.interleaveArray(this.versions, 17); // 0/16 to 16/16
				const prefetch = this.batchRevisions(interleaved);
				if (prefetch.length) attempts.unshift(prefetch.join('|'));
			}
			const params = {
				action: 'query',
				format: 'json',
				prop: 'revisions',
				rvprop: 'ids|content',
				rvslots: 'main'
			};
			let result;
			for (const ids of attempts) {
				try {
					result = await api.get({ ...params, revids: ids });
					break;
				} catch (e) {
					console.warn(`Failed to fetch revisions ${ids}: ${e.message}`);
				}
			}
			if (!result) return null;
			const pageObj = Object.values(result.query.pages)[0];
			if (!pageObj?.revisions) return null;
			for (const rev of pageObj?.revisions) {
				let content = rev.slots?.main?.['*'] ?? null;
				if (content !== null) {
					PageBlame.versionCache.set(rev.revid, content);
				}
			}
			return PageBlame.versionCache.get(versionId) ?? null;
		}

		resultLinks(versionId) {
			const encodedTarget = encodeURIComponent(this.target);
			return {
				revision: `${scriptPath}?title=${encodedTarget}&oldid=${versionId}`,
				diff: `${scriptPath}?title=${encodedTarget}&diff=prev&oldid=${versionId}`
			};
		}
	}

	class MessageHandler {
		constructor(container) {
			container.$element.empty();
			this.container = container;
		}

		clear() {
			if (!this.container.$element?.[0]?.querySelector('.blame-message-error')) {
				this.container.$element.empty();
			}
		}

		add(level, ...items) {
			this.container.toggle(true);
			const message = document.createElement('div');
			message.className = `blame-message blame-message-${level}`;
			items.forEach(i => {
				if (i instanceof Node) {
					message.append(i);
				}
				else {
					const span = document.createElement('span');
					span.className = 'blame-message-status';
					span.textContent = i;
					message.append(span);
				}
			});
			this.container.$element.append(message);
			const first = this.container.$element.find('div').first()[0];
			if (first) first.scrollIntoView({ behavior: 'smooth', block: 'center' });
			mw.hook('wikipage.content').fire(this.container.$element);
		}

		error(...args) { this.add('error', ...args); }
		notice(...args) { this.add('notice', ...args); }
		success(...args) { this.add('success', ...args); }
		warning(...args) { this.add('warning', ...args); }
	}
});