User:TonySt/RevertMonitor.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
This user script seems to have a documentation page at User:TonySt/RevertMonitor.
// <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>