User:TonySt/RevertMonitor.js
Appearance
From Wikipedia, the free encyclopedia
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. */ classApiService{ /** * @param {function} logger - A logging function for debugging API calls. */ constructor(logger){ this.api=newmw.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}); returnthis.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. */ asyncfetchRawActions(){ constrights=awaitmw.user.getRights(); constlimit=rights.includes("rollback")?200:50; const[rollbackData,undoData]=awaitPromise.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'}) ]); constfilteredUndos=undoData.query.recentchanges.filter(rc=>rc.tags.includes('twinkle')||rc.tags.includes('RedWarn')||rc.tags.includes('Ultraviolet')); constallActions=newMap(); 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.'); returnnull; } returnArray.from(allActions.values()).sort((a,b)=>newDate(b.timestamp)-newDate(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. */ asyncprocessAction(action){ try{ consthistData=awaitthis._apiGet({action:'query',prop:'revisions',titles:action.title,rvlimit:2,rvstartid:action.revid,rvprop:'ids|user',format:'json',formatversion:'2'}); constrevisions=histData.query.pages[0].revisions; if(revisions&&revisions.length>1){ return{ timestamp:newDate(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.`); returnnull; }catch(error){ console.error(`Failed to process action on "${action.title}":`,error); returnnull; } } /** * 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). */ asyncfetchUserGroups(usernames){ constuserGroups=newMap(); if(usernames.length===0)returnuserGroups; for(leti=0;i<usernames.length;i+=49){ constchunk=usernames.slice(i,i+49); try{ constgroupData=awaitthis._apiGet({action:'query',list:'users',ususers:chunk.join('|'),usprop:'groups'}); groupData.query.users.forEach(user=>{ letgroup=null; if(user.groups){ if(user.groups.includes('sysop'))group='sysop'; elseif(user.groups.includes('rollbacker'))group='rollbacker'; } userGroups.set(user.name,group); }); }catch(error){ console.error('Could not fetch user groups:',error); } } returnuserGroups; } /** * 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. */ asyncfetchBlocks(rawUsernames){ constusernames=rawUsernames.filter(item=>item); constallBlockedUsers=newSet(); if(usernames.length===0)returnallBlockedUsers; try{ for(leti=0;i<usernames.length;i+=50){ constchunk=usernames.slice(i,i+50); constresponse=awaitthis._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}`); } returnallBlockedUsers; } /** * 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. */ asyncfetchPageContent(pageName){ try{ constpageData=awaitthis._apiGet({action:'query',prop:'revisions',titles:`${pageName}`,rvprop:'content',rvslots:'main',format:'json',formatversion:'2'}); constpage=pageData.query.pages[0]; if(page.missing||!page.revisions)returnnull; returnpage.revisions[0].slots.main.content; }catch(error){ console.error(`Could not check content for page ${pageName}:`,error); returnnull; } } /** * 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. */ asyncfetchTalkPageHistory(username){ try{ consthistoryData=awaitthis._apiGet({action:'query',prop:'revisions',titles:`User_talk:${username}`,rvprop:'timestamp|user|comment',rvlimit:50,format:'json',formatversion:'2'}); returnhistoryData.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. */ asyncfetchReportedUsersAIV(){ constpageName='Wikipedia:Administrator_intervention_against_vandalism'; constreportedUsers=newSet(); try{ constcontent=awaitthis.fetchPageContent(pageName); if(content){ constmatches=content.matchAll(/\{\{vandal\|(.*?)\}\}/gi); for(constmatchofmatches){ reportedUsers.add(match[1]); } } this._log(`Found ${reportedUsers.size} users reported to AIV.`); returnreportedUsers; }catch(error){ console.error(`Could not fetch or parse AIV page:`,error); returnreportedUsers;// Return empty set on error } } } /** * Handles all data processing, filtering, and grouping logic. Does not interact with the API or DOM. */ classDataProcessor{ /** * @param {string} currentMonthHeader The exact string for the current month's section header (e.g., "== October 2025 =="). * @param {RegExp} warningRegex The regular expression to find warning templates. */ constructor(currentMonthHeader,warningRegex){ this.currentMonthHeader=currentMonthHeader; 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[]; constfoundWarnings=newSet(); letcurrentIndex=0; while(true){ constsectionStartIndex=content.indexOf(this.currentMonthHeader,currentIndex); if(sectionStartIndex===-1){ break;// No more sections for this month found } constnextSectionIndex=content.indexOf('\n== ',sectionStartIndex+1); constsectionContent=nextSectionIndex===-1 ?content.substring(sectionStartIndex) :content.substring(sectionStartIndex,nextSectionIndex); for(constmatchofsectionContent.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; } returnArray.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){ constgrouped=newMap(); for(constresultofresults){ constusername=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); } } returngrouped; } /** * 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){ constfinalWarningRegex=/final|level-?4(im)?/i; if(!userData.talkPage.warnings.includes('final'))returnfalse; constlatestRevertTimestamp=userData.reverts[0].timestamp; constreverters=newSet(userData.reverts.map(rv=>rv.reverter)); letlatestWarningTimestamp=null; for(constrevofrevisions){ if(reverters.has(rev.user)&&finalWarningRegex.test(rev.comment)){ latestWarningTimestamp=newDate(rev.timestamp); break; } } returnlatestWarningTimestamp&&latestRevertTimestamp>latestWarningTimestamp; } } /** * Manages all DOM manipulation and UI event handling. */ classUIManager{ /** * @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(){ constpageTitle=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 */ constlangButton=document.querySelector('#p-lang-btn'); if(langButton){ langButton.style.display='none'; } constcontainer=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> `; constfilterGroup=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',()=>{ constpanel=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 constsettingControls=[ '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 constslider=document.getElementById('tonyst-revertmonitor-filter-slider'); constlabel=document.getElementById('tonyst-revertmonitor-filter-label'); constfilterLabels=['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){ constrefreshButton=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){ constcontainer=document.querySelector('#tonyst-revertmonitor-table-container'); if(!container)return; constfinalResults=this._applyFilters(groupedResults); 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; } consttable=this._createTableStructure(); consttbody=table.querySelector('tbody'); for(const[username,userData]offinalResults.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){ letfilteredEntries=[...groupedResults.entries()]; consthideBlocked=document.getElementById('tonyst-revertmonitor-hide-blocked').checked; if(hideBlocked){ filteredEntries=filteredEntries.filter(([,userData])=>!userData.isBlocked); } constshowAdminsOnly=document.getElementById('tonyst-revertmonitor-admins-only').checked; if(showAdminsOnly){ filteredEntries=filteredEntries.filter(([,userData])=> userData.reverts.some(rv=>this.app.userGroupsCache.get(rv.reverter)) ); } constshowMultipleRevertersOnly=document.getElementById('tonyst-revertmonitor-multiple-reverters').checked; if(showMultipleRevertersOnly){ filteredEntries=filteredEntries.filter(([,userData])=>{ constuniqueReverters=newSet(userData.reverts.map(rv=>rv.reverter)); returnuniqueReverters.size>1; }); } constsliderValue=document.getElementById('tonyst-revertmonitor-filter-slider').value; constvalueMap=['all','1','2','3','final']; constfilterValue=valueMap[sliderValue]; filteredEntries=filteredEntries.filter(([,userData])=>{ constwarnings=userData.talkPage.warnings; switch(filterValue){ case'all':returntrue; case'1':returnwarnings.length>0; case'2':returnwarnings.includes('2')||warnings.includes('3')||warnings.includes('final'); case'3':returnwarnings.includes('3')||warnings.includes('final'); case'final':returnwarnings.includes('final'); default:returntrue; } }); constsortedEntries=filteredEntries.sort((a,b)=>{ constlastRvA=a[1].reverts[0].timestamp; constlastRvB=b[1].reverts[0].timestamp; returnlastRvB-lastRvA;// Sort descending (newest first) }); constdisplayLimit=parseInt(document.getElementById('tonyst-revertmonitor-display-limit').value,10)||100; constlimitedEntries=sortedEntries.slice(0,displayLimit); returnnewMap(limitedEntries); } /** * Creates the main table element with its header. * @returns {HTMLTableElement} The created table element. * @private */ _createTableStructure(){ consttable=document.createElement('table'); table.className='wikitable sortable tonyst-revertmonitor-table'; table.style.width='100%'; constthead=table.createTHead(); constheaderRow=thead.insertRow(); ['','Reverted User','Warnings/Reports','Last Revert','Reverted by','Page Title(s)'].forEach((text,index)=>{ constth=document.createElement('th'); th.textContent=text; if(index===0){ th.style.width='20px'; th.classList.add('unsortable'); consttoggleAll=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); }); constwarningBoxesHeaderRow=thead.insertRow(); ['1','2','3','F','V'].forEach((text,index)=>{ constth=document.createElement('th'); th.textContent=text; th.style.width='20px'; th.classList.add('unsortable'); warningBoxesHeaderRow.appendChild(th); }); table.createTBody(); returntable; } /** * 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){ constnumReverts=userData.reverts.length; constpostFinalClass=userData.hasPostFinalWarningRevert?'tonyst-revertmonitor-post-final-revert':''; constreportedToAIVClass=userData.isReportedToAIV?'tonyst-revertmonitor-isreportedtoAIV':''; constisMultiRevert=numReverts>1; // Create the main row (either the single row or the summary row) constmainRow=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._createArrow(username,'▶'):document.createElement('td')).parentNode; constuserCell=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){ constaggregated=this._aggregateRevertData(userData.reverts); constsummaryTimeCell=mainRow.insertCell(); this._createLink(summaryTimeCell,this._getLinkUrl('diff',aggregated.latestRevid,aggregated.latestOldRevid,aggregated.latestPageTitle),aggregated.latestTimestamp); summaryTimeCell.style.whiteSpace='nowrap'; mainRow.insertCell().innerHTML=aggregated.revertersHtml; mainRow.insertCell().innerHTML=aggregated.pagesHtml; // Create hidden detail rows userData.reverts.forEach((rv,index)=>{ constdetailRow=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 constarrowCell=detailRow.insertCell(); arrowCell.rowSpan=numReverts; arrowCell.appendChild(this._createArrow(username,'▼')); constuserCell=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){ consttimeCell=row.insertCell(); this._createLink(timeCell,this._getLinkUrl('diff',rv.revid,rv.old_revid,rv.pageTitle),rv.timestamp.toLocaleTimeString()); timeCell.style.whiteSpace='nowrap'; constreverterCell=row.insertCell(); constgroup=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':'')); constpageCell=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){ constcell=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){ constwarnings=userData.talkPage.warnings; consttalkPageHref=this._getLinkUrl('talk',userData.revertedUser); constaivHref=this._getLinkUrl('aiv'); constlevels=['1','2','3','final','AIV']; constblankSpan=document.createElement('span'); blankSpan.textContent='\u00A0'; for(constleveloflevels){ constcell=row.insertCell(); cell.className='warning-level-cell'; if(rowspan>0)cell.rowSpan=rowspan; lethasWarning=false; lethref=talkPageHref; if(level==='AIV'){ hasWarning=userData.isReportedToAIV; href=aivHref; }else{ hasWarning=warnings.includes(level); } if(hasWarning){ this._createLink(cell,href,'X'); }else{ cell.appendChild(blankSpan); } } } /** * 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){ constencodedName=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': constencodedTitle=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=''){ constlink=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)'; returnnull; } /** * 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 */ _createArrow(username,direction){ constarrow=document.createElement('span'); arrow.className='toggle-arrow'; arrow.textContent=direction; arrow.dataset.user=username.replace(/"/g,"'"); arrow.style.cursor='pointer'; arrow.style.userSelect='none'; constcell=document.createElement('td'); cell.appendChild(arrow); returncell; } /** * 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){ constreverterCounts=newMap(); constpageCounts=newMap(); constlatestRevert=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); }); constformatLinks=(countsMap,type)=>Array.from(countsMap.entries()).map(([name,count])=>{ constgroup=type==='user'?this.app.userGroupsCache.get(name):null; constclassName=group==='sysop'?'tonyst-revertmonitor-sysop':(group==='rollbacker'?'tonyst-revertmonitor-rollbacker':''); consthref=this._getLinkUrl(type==='user'?'contribs':'history',name); constlink=`<a href="${href}" target="_blank" class="${className}">${name}</a>`; returncount>1?`${link} (${count})`:link; }).join(', '); return{ latestTimestamp:latestRevert.timestamp.toLocaleTimeString(), latestRevid:latestRevert.revid, latestOldRevid:latestRevert.old_revid, latestPageTitle:latestRevert.pageTitle, revertersHtml:formatLinks(reverterCounts,'user'), pagesHtml: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)=>{ consttarget=e.target; if(target.id==='tonyst-revertmonitor-toggle-all'){ constisExpanding=target.textContent==='▶'; target.textContent=isExpanding?'▼':'▶'; consttbody=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?'▼':'▶'); }elseif(target.classList.contains('toggle-arrow')){ consttargetUser=target.dataset.user; if(!targetUser)return; consttbody=table.querySelector('tbody'); constsummaryRow=tbody.querySelector(`.revert-summary[data-user="${targetUser}"]`); constdetailRows=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. */ classRevertMonitor{ constructor(){ this.DEBUG_MODE=false; this.apiService=newApiService(this._log.bind(this)); constnow=newDate(); constcurrentMonthHeader=`== ${now.toLocaleString('default',{month:'long'})} ${now.getFullYear()} ==`; constwarningRegex=/<!-- Template:uw-.*?(1|2|3|4(?:im)?) -->/g; this.dataProcessor=newDataProcessor(currentMonthHeader,warningRegex); this.uiManager=newUIManager(this); this.userCache=newMap(); this.isFetching=false; this.autoRefreshInterval=null; this.userGroupsCache=newMap(); } /** * 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. */ asyncrun(){ this.uiManager.setup(); this.loadSettings(); awaitthis._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(){ letsettings={}; try{ conststoredSettings=mw.storage.getObject("tonyst-revertmonitor_settings"); if(storedSettings){ settings=storedSettings; } }catch(e){ console.error("Could not parse settings from mw.storage",e); } constdefaults={ warningFilter:'1', adminsOnly:false, multipleReverters:false, hideBlocked:false, displayLimit:25, memoryLimit:5000 }; constfinalSettings={...defaults,...settings}; constslider=document.getElementById('tonyst-revertmonitor-filter-slider'); constlabel=document.getElementById('tonyst-revertmonitor-filter-label'); constfilterLabels=['Show all','Level 1+','Level 2+','Level 3+','Final only']; // Use '1' as default if the saved value is invalid letsliderValue=parseInt(finalSettings.warningFilter,10); if(isNaN(sliderValue)||sliderValue<0||sliderValue>4){ // This handles migration from old string values like 'final' or 'all' constoldMap={'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(){ constsettings={ 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); } /** * 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{ constreportedUsersAIV=awaitthis.apiService.fetchReportedUsersAIV(); constrawActions=awaitthis.apiService.fetchRawActions(); if(!rawActions){ this.uiManager.displayResults(newMap()); return; } constsimpleResults=awaitPromise.all(rawActions.map(action=>this.apiService.processAction(action))); constvalidResults=simpleResults.filter(r=>r!==null); this._log(`Identified ${validResults.length} reverted users from raw actions.`); awaitthis._updateUserGroupsCache(validResults); constblockedUsers=awaitthis._getBlockedUsers(validResults); consttalkPageInfos=awaitthis._updateTalkPageData(validResults); letgroupedResults=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]ofgroupedResults.entries()){ if(userData.talkPage.warnings.includes('final')){ this._log(`User ${username} has a final warning, checking their talk page history.`); constrevisions=awaitthis.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); constrpm=this.countRevertsInLastNMinutes(5)/5; constrpmElement=document.querySelector('#tonyst-revertmonitor-rpm').innerHTML=`<abbr title="Reverts per minute (over the last 5 minutes)">RPM</abbr>: ${rpm}`; 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){ constnewReverters=[...newSet(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.`); constnewGroups=awaitthis.apiService.fetchUserGroups(newReverters); for(const[user,group]ofnewGroups.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){ constrevertedUsers=[...newSet(simpleResults.map(r=>r.revertedUser))]; constusersToBlockCheck=newSet(revertedUsers); for(const[username,userData]ofthis.userCache.entries()){ if(userData.talkPage.warnings.includes('3')||userData.talkPage.warnings.includes('final')){ usersToBlockCheck.add(username); } } this._log(`Checking block status for ${usersToBlockCheck.size} users.`); returnthis.apiService.fetchBlocks(Array.from(usersToBlockCheck)); } /** * 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){ constrevertedUsers=[...newSet(simpleResults.map(r=>r.revertedUser))]; consttalkPageInfosToKeep=newMap(); constusersToCheckTalkPage=[]; for(constuserofrevertedUsers){ constoldUserData=this.userCache.get(user); constlatestNewRevert=simpleResults.find(r=>r.revertedUser===user); if(!oldUserData||!oldUserData.reverts.length||oldUserData.reverts[0].timestamp.getTime()!==latestNewRevert.timestamp.getTime()){ usersToCheckTalkPage.push(user); }else{ constwarnings=oldUserData.talkPage.warnings; if(warnings.includes('3')&&!warnings.includes('final')){ usersToCheckTalkPage.push(user); }else{ talkPageInfosToKeep.set(user,oldUserData.talkPage.warnings); } } } letnewTalkPageInfos=newMap(); if(usersToCheckTalkPage.length>0){ this._log(`Fetching talk page content for ${usersToCheckTalkPage.length} users.`); consttalkPagePromises=usersToCheckTalkPage.map(async(user)=>{ constcontent=awaitthis.apiService.fetchPageContent(`User_talk:${user}`); return[user,this.dataProcessor.processTalkPage(content)]; }); newTalkPageInfos=newMap(awaitPromise.all(talkPagePromises)); } returnnewMap([...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. constmergedCache=newMap([...this.userCache,...newlyGroupedResults]); // Sort the entire cache by the timestamp of the most recent revert for each user. constsortedCacheArray=[...mergedCache.entries()].sort((a,b)=>{ constlastRvATime=a[1].reverts[0].timestamp.getTime(); constlastRvBTime=b[1].reverts[0].timestamp.getTime(); returnlastRvBTime-lastRvATime;// Newest first }); // Trim the sorted cache to the memory limit. constmemoryLimit=parseInt(document.getElementById('tonyst-revertmonitor-memory-limit').value,10)||5000; constprunedArray=sortedCacheArray.slice(0,memoryLimit); // Rebuild the cache map from the sorted and pruned array. this.userCache=newMap(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(typeofminutes!=='number'||minutes<=0){ console.error("Please provide a positive number of minutes."); return0; } constnow=newDate(); constcutoffTime=newDate(now.getTime()-(minutes*60000)); this._log(`Counting reverts since ${cutoffTime.toISOString()}`); constfoundRevids=newSet(); for(constuserDataofthis.userCache.values()){ for(constrevertofuserData.reverts){ // revert.timestamp is already a Date object if(revert.timestamp>cutoffTime){ if(!foundRevids.has(revert.revid)){ foundRevids.add(revert.revid); } } } } constcount=foundRevids.size; returncount; } } if(mw.config.get("wgRelevantPageName")==="User:TonySt/RevertMonitor/run"&&mw.config.get("wgAction")==="view"){ window.TonyStRevertMonitor=newRevertMonitor(); 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>