User:GeneralNotability/spihelper.js

// // @ts-check // GeneralNotability's rewrite of Tim's SPI helper script // v2.6.0 "Userboxitis"

/* global mw, $, importStylesheet, importScript, displayMessage, spiHelperCustomOpts */

// Adapted from User:Mr.Z-man/closeAFD importStylesheet('User:GeneralNotability/spihelper.css') importScript('User:Timotheus Canens/displaymessage.js')

// Typedefs /** * @typedef SelectOption * @type {Object} * @property {string} label Text to display in the drop-down * @property {string} value Value to return if this option is selected * @property {boolean} selected Whether this item should be selected by default * @property {boolean=} disabled Whether this item should be disabled */

/** * @typedef BlockEntry * @type {Object} * @property {string} username Username to block * @property {string} duration Duration of block * @property {boolean} acb If set, account creation is blocked * @property {boolean} ab Whether autoblock is enabled (for registered users)/ *    logged-in users are blocked (for IPs) * @property {boolean} ntp If set, talk page access is blocked * @property {boolean} nem If set, email access is blocked * @property {string} tpn Type of talk page notice to apply on block */

/** * @typedef TagEntry * @type {Object} * @property {string} username Username to tag * @property {string} tag Tag to apply to user * @property {string} altmasterTag Altmaster tag to apply to user, if relevant * @property {boolean} blocking Whether this account is marked for block as well */

/** * @typedef ParsedArchiveNotice * @type {Object} * @property {string} username Case username * @property {boolean} xwiki Whether the crosswiki flag is set * @property {boolean} deny Whether the deny flag is set */

// Globals // User-configurable settings, these are the defaults but will be updated by // spiHelper_loadSettings const spiHelperSettings = { // Choices are 'watch' (unconditionally add to watchlist), 'preferences' // (follow default preferences), 'nochange' (don't change the watchlist // status of the page), and 'unwatch' (unconditionally remove) watchCase: 'preferences', watchCaseExpiry: 'indefinite', watchArchive: 'nochange', watchArchiveExpiry: 'indefinite', watchTaggedUser: 'preferences', watchTaggedUserExpiry: 'indefinite', watchNewCats: 'nochange', watchNewCatsExpiry: 'indefinite', watchBlockedUser: true, watchBlockedUserExpiry: 'indefinite', // Lets people disable clerk options if they're not a clerk clerk: true, // Log all actions to Special:MyPage/spihelper_log log: false, // Reverse said log, so that the newest actions are at the top. reversed_log: false, // Enable the "move section" button iUnderstandSectionMoves: false, // These are for debugging to view as other roles. If you're picking apart the code and // decide to set these (especially the CU option), it is YOUR responsibility to make sure // you don't do something that violates policy debugForceCheckuserState: null, debugForceAdminState: null }

/** @type {string} Name of the SPI page in wiki title form * (e.g. FAMEPedia:Sockpuppet investigations/Test) */ let spiHelperPageName = mw.config.get('wgPageName').replace(/_/g, ' ')

/** @type {number} The main page's ID - used to check if the page * has been edited since we opened it to prevent edit conflicts */ let spiHelperStartingRevID = mw.config.get('wgCurRevisionId')

// Just the username part of the case let spiHelperCaseName = spiHelperPageName.replace(/FAMEPedia:Sockpuppet investigations\//g, '')

/** list of section IDs + names corresponding to separate investigations */ let spiHelperCaseSections = []

/** @type {?number} Selected section, "null" means that we're opearting on the entire page */ let spiHelperSectionId = null

/** @type {?string} Selected section's name (e.g. "10 June 2020") */ let spiHelperSectionName = null

/** @type {ParsedArchiveNotice} */ let spiHelperArchiveNoticeParams

/** Map of top-level actions the user has selected */ const spiHelperActionsSelected = { Case_act: false, Block: false, Note: false, Close: false, Rename: false, Archive: false, SpiMgmt: false }

/** @type {BlockEntry[]} Requested blocks */ const spiHelperBlocks = []

/** @type {TagEntry[]} Requested tags */ const spiHelperTags = []

/** @type {string[]} Requested global locks */ const spiHelperGlobalLocks = []

// Count of unique users in the case (anything with a checkuser, checkip, user, ip, or vandal template on the page) let spiHelperUserCount = 0 const spiHelperSectionRegex = /^(?:===[^=]*===|=====[^=]*=====)\s*$/m

/** @type {SelectOption[]} List of possible selections for tagging a user in the block/tag interface */ const spiHelperTagOptions = [ { label: 'None', selected: true, value: '' }, { label: 'Suspected sock', value: 'blocked', selected: false }, { label: 'Proven sock', value: 'proven', selected: false }, { label: 'CU confirmed sock', value: 'confirmed', selected: false }, { label: 'Blocked master', value: 'master', selected: false }, { label: 'CU confirmed master', value: 'sockmasterchecked', selected: false }, { label: '3X banned master', value: 'bannedmaster', selected: false } ]

/** @type {SelectOption[]} List of possible selections for tagging a user's altmaster in the block/tag interface */ const spiHelperAltMasterTagOptions = [ { label: 'None', selected: true, value: '' }, { label: 'Suspected alt master', value: 'suspected', selected: false }, { label: 'Proven alt master', value: 'proven', selected: false } ]

/** @type {SelectOption[]} List of templates that CUs might insert */ const spiHelperCUTemplates = [ { label: 'CU templates', selected: true, value: '', disabled: true }, { label: 'Confirmed', selected: false, value: '✅' }, { label: 'Confirmed/No Comment', selected: false, value: '✅' }, { label: 'Indistinguishable', selected: false, value: '✅' }, { label: 'Likely', selected: false, value: '' }, { label: 'Possilikely', selected: false, value: '' }, { label: 'Possible', selected: false, value: '' }, { label: 'Unlikely', selected: false, value: '' }, { label: 'Unrelated', selected: false, value: '❌' }, { label: 'Inconclusive', selected: false, value: '' }, { label: 'Need behavioral eval', selected: false, value: '' }, { label: 'No sleepers', selected: false, value: '' }, { label: 'Stale', selected: false, value: '' }, { label: 'No comment (IP)', selected: false, value: '' } ]

/** @type {SelectOption[]} Templates that a clerk or admin might insert */ const spiHelperAdminTemplates = [ { label: 'Admin/clerk templates', selected: true, value: '', disabled: true }, { label: 'Duck', selected: false, value: '' }, { label: 'Megaphone Duck', selected: false, value: '' }, { label: 'Blocked and tagged', selected: false, value: '' }, { label: 'Blocked, no tags', selected: false, value: '' }, { label: 'Blocked, awaiting tags', selected: false, value: '' }, { label: 'Blocked, tagged, closed', selected: false, value: '' }, { label: 'Diffs needed', selected: false, value: '' }, { label: 'Locks requested', selected: false, value: '' } ]

// Regex to match the case status, group 1 is the actual status const spiHelperCaseStatusRegex = //i // Regex to match closed case statuses (close or closed) const spiHelperCaseClosedRegex = /^closed?$/i

const spiHelperClerkStatusRegex = //i

const spiHelperSockSectionWithNewlineRegex = /====\s*Suspected sockpuppets\s*====\n*/i

const spiHelperAdminSectionWithPrecedingNewlinesRegex = /\n*\s*====\s* Clerk, CheckUser, and\/or patrolling admin comments<\/big>\s*====\s*/i

const spiHelperCUBlockRegex = //i

const spiHelperArchiveNoticeRegex = //i

const spiHelperPriorCasesRegex = //i

// regex to remove hidden characters from form inputs - they mess up some things, // especially mw.util.isIP const spiHelperHiddenCharNormRegex = /\u200E/

const spihelperAdvert = ' (using spihelper.js)'

// The current wiki's interwiki prefix const spiHelperInterwikiPrefix = spiHelperGetInterwikiPrefix

// Map of active operations (used as a "dirty" flag for beforeunload) // Values are strings representing the state - acceptable values are 'running', 'success', 'failed' const spiHelperActiveOperations = new Map

// Actually put the portlets in place if needed if (mw.config.get('wgPageName').includes('FAMEPedia:Sockpuppet_investigations/') && !mw.config.get('wgPageName').includes('FAMEPedia:Sockpuppet_investigations/SPI/') &&  !mw.config.get('wgPageName').includes('/Archive')) { mw.loader.load('mediawiki.user') $(spiHelperAddLink) }

// Main functions - do the meat of the processing and UI work

const spiHelperTopViewHTML = `  Handling SPI case      Change case status    Change SPI options    Block/tag socks   <input type="checkbox" name="spiHelper_Comment" id="spiHelper_Comment" /> <label for="spiHelper_Comment">Note/comment </li>  <input type="checkbox" name="spiHelper_Close" id="spiHelper_Close")" />     <label for="spiHelper_Close">Close case     </li>          <input type="checkbox" name="spiHelper_Move" id="spiHelper_Move" />      <label for="spiHelper_Move" id="spiHelper_moveLabel">Move/merge full case (Clerk only)     </li>          <input type="checkbox" name="spiHelper_Archive" id="spiHelper_Archive"/>      <label for="spiHelper_Archive">Archive case (Clerk only)     </li>  </ul>  <input type="button" id="spiHelper_GenerateForm" name="spiHelper_GenerateForm" value="Continue" onclick="spiHelperGenerateForm" /> `

/** * Initialization functions for spiHelper, displays the top-level menu */ async function spiHelperInit { 'use strict' spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs

// Load archivenotice params spiHelperArchiveNoticeParams = await spiHelperParseArchiveNotice(spiHelperPageName)

// First, insert the template text displayMessage(spiHelperTopViewHTML)

// Narrow search scope const $topView = $('#spiHelper_topViewDiv', document)

// Next, modify what's displayed // Set the block selection label based on whether or not the user is an admin $('#spiHelper_blockLabel', $topView).text(spiHelperIsAdmin ? 'Block/tag socks' : 'Tag socks')

// Wire up a couple of onclick handlers $('#spiHelper_Move', $topView).on('click', function {    spiHelperUpdateArchive  }) $('#spiHelper_Archive', $topView).on('click', function {    spiHelperUpdateMove  })

// Generate the section selector const $sectionSelect = $('#spiHelper_sectionSelect', $topView) $sectionSelect.on('change', => {    spiHelperSetCheckboxesBySection  })

// Add the dates to the selector for (let i = 0; i < spiHelperCaseSections.length; i++) { const s = spiHelperCaseSections[i] $(' ').val(s.index).text(s.line).appendTo($sectionSelect) } // All-sections selector...deliberately at the bottom, the default should be the first section $(' ').val('all').text('All Sections').appendTo($sectionSelect)

// Hide block and close from non-admin non-clerks if (!(spiHelperIsAdmin || spiHelperIsClerk)) { $('.spiHelper_adminClerkClass', $topView).hide }

// Hide move and archive from non-clerks if (!spiHelperIsClerk) { $('.spiHelper_clerkClass', $topView).hide }

// Set the checkboxes to their default states spiHelperSetCheckboxesBySection }

const spiHelperActionViewHTML = ` <div id="spiHelper_actionViewDiv"> <a id="spiHelper_backLink">Back to top menu</a>

Handling SPI case <div id="spiHelper_actionView"> Changing case status <label for="spiHelper_CaseAction">New status: <select id="spiHelper_CaseAction"/> <div id="spiHelper_spiMgmtView"> Changing SPI settings <ul>  <input type="checkbox" id="spiHelper_spiMgmt_crosswiki" /> <label for="spiHelper_Case_Action">Case is crosswiki </li>  <input type="checkbox" id="spiHelper_spiMgmt_deny" /> <label for="spiHelper_Case_Action">Socks should not be tagged per DENY </li> </ul> <div id="spiHelper_blockTagView"> <h4 id="spiHelper_blockTagHeader">Blocking and tagging socks <ul>  <input type="checkbox" name="spiHelper_noblock" id="spiHelper_noblock" /> <label for="spiHelper_noblock">Do not make any blocks (this overrides the individual "Blk" boxes below) </li>  <input type="checkbox" name="spiHelper_override" id="spiHelper_override" /> <label for="spiHelper_override">Override any existing blocks </li>  <input type="checkbox" name="spiHelper_cublock" id="spiHelper_cublock" /> <label for="spiHelper_cublock">Mark blocks as Checkuser blocks. </li>  <input type="checkbox" name="spiHelper_cublockonly" id="spiHelper_cublockonly" /> <label for="spiHelper_cublockonly"> Suppress the usual block summary and only use and  (no effect if "mark blocks as CU blocks" is not checked). </li>  <input type="checkbox" checked="checked" name="spiHelper_blocknoticemaster" id="spiHelper_blocknoticemaster" /> <label for="spiHelper_blocknoticemaster">Add talk page notice when (re)blocking the sockmaster. </li>  <input type="checkbox" checked="checked" name="spiHelper_blocknoticesocks" id="spiHelper_blocknoticesocks" /> <label for="spiHelper_blocknoticesocks">Add talk page notice when blocking socks. </li>  <input type="checkbox" name="spiHelper_blanktalk" id="spiHelper_blanktalk" /> <label for="spiHelper_blanktalk">Blank the talk page when adding talk notices. </li>  <input type="checkbox" name="spiHelper_hidelocknames" id="spiHelper_hidelocknames" /> <label for="spiHelper_hidelocknames">Hide usernames when requesting global locks. </li> </ul> <input type="button" id="moreSerks" value="Add Row" onclick="spiHelperAddBlankUserLine;"/> <div id="spiHelper_closeView"> Marking case as closed <input type="checkbox" checked="checked" id="spiHelper_CloseCase" /> <label for="spiHelper_CloseCase">Close this SPI case <div id="spiHelper_moveView"> <h4 id="spiHelper_moveHeader">Move section <label for="spiHelper_moveTarget">New sockmaster username: <input type="text" name="spiHelper_moveTarget" id="spiHelper_moveTarget" /> <div id="spiHelper_archiveView"> Archiving case <input type="checkbox" checked="checked" name="spiHelper_ArchiveCase" id="spiHelper_ArchiveCase" /> <label for="spiHelper_ArchiveCase">Archive this SPI case <div id="spiHelper_commentView"> Comments <select id="spiHelper_noteSelect"/> <select class="spiHelper_adminClerkClass" id="spiHelper_adminSelect"/> <select class="spiHelper_cuClass" id="spiHelper_cuSelect"/> <label for="spiHelper_CommentText">Comment: <textarea rows="3" cols="80" id="spiHelper_CommentText">* <a id="spiHelper_previewLink">Preview</a> <div class="spihelper-previewbox" id="spiHelper_previewBox" hidden/> <input type="button" id="spiHelper_performActions" value="Done" /> ` /** * Big function to generate the SPI form from the top-level menu selections * * Would fail ESlint no-unused-vars due to only being * referenced in an onclick event * * @return {Promise } */ // eslint-disable-next-line no-unused-vars async function spiHelperGenerateForm { 'use strict' spiHelperUserCount = 0 const $topView = $('#spiHelper_topViewDiv', document) spiHelperActionsSelected.Case_act = $('#spiHelper_Case_Action', $topView).prop('checked') spiHelperActionsSelected.Block = $('#spiHelper_BlockTag', $topView).prop('checked') spiHelperActionsSelected.Note = $('#spiHelper_Comment', $topView).prop('checked') spiHelperActionsSelected.Close = $('#spiHelper_Close', $topView).prop('checked') spiHelperActionsSelected.Rename = $('#spiHelper_Move', $topView).prop('checked') spiHelperActionsSelected.Archive = $('#spiHelper_Archive', $topView).prop('checked') spiHelperActionsSelected.SpiMgmt = $('#spiHelper_SpiMgmt', $topView).prop('checked') const pagetext = await spiHelperGetPageText(spiHelperPageName, false, spiHelperSectionId) if (!(spiHelperActionsSelected.Case_act || spiHelperActionsSelected.Note || spiHelperActionsSelected.Close || spiHelperActionsSelected.Archive || spiHelperActionsSelected.Block || spiHelperActionsSelected.Rename || spiHelperActionsSelected.SpiMgmt)) { displayMessage('') return }

displayMessage(spiHelperActionViewHTML)

// Reduce the scope that jquery operates on const $actionView = $('#spiHelper_actionViewDiv', document)

// Wire up the action view $('#spiHelper_backLink', $actionView).on('click', => {    spiHelperInit  }) if (spiHelperActionsSelected.Case_act) { const result = spiHelperCaseStatusRegex.exec(pagetext) let casestatus = '' if (result) { casestatus = result[1] }   const canAddCURequest = (casestatus === '' || /^(?:admin|moreinfo|cumoreinfo|hold|cuhold|clerk|open)$/i.test(casestatus)) const cuRequested = /^(?:CU|checkuser|CUrequest|request|cumoreinfo)$/i.test(casestatus) const cuEndorsed = /^(?:endorse(d)?)$/i.test(casestatus) const cuCompleted = /^(?:inprogress|checking|relist(ed)?|checked|completed|declined?|cudeclin(ed)?)$/i.test(casestatus)

/** @type {SelectOption[]} Generated array of values for the case status select box */ const selectOpts = [ { label: 'No action', value: 'noaction', selected: true } ]   if (spiHelperCaseClosedRegex.test(casestatus)) { selectOpts.push({ label: 'Reopen', value: 'reopen', selected: false }) } else if (spiHelperIsClerk && casestatus === 'clerk') { // Allow clerks to change the status from clerk to open. // Used when clerk assistance has been given and the case previously had the status 'open'. selectOpts.push({ label: 'Mark as open', value: 'open', selected: false }) } else if (spiHelperIsAdmin && casestatus === 'admin') { // Allow admins to change the status to open from admin // Used when admin assistance has been given to the non-admin clerk and the case previously had the status 'open'. selectOpts.push({ label: 'Mark as open', value: 'open', selected: false }) }   if (spiHelperIsCheckuser) { selectOpts.push({ label: 'Mark as in progress', value: 'inprogress', selected: false }) }   if (spiHelperIsClerk || spiHelperIsAdmin) { selectOpts.push({ label: 'Request more information', value: 'moreinfo', selected: false }) }   if (canAddCURequest) { // Statuses only available if the case could be moved to "CU requested" selectOpts.push({ label: 'Request CU', value: 'CUrequest', selected: false }) if (spiHelperIsClerk) { selectOpts.push({ label: 'Request CU and self-endorse', value: 'selfendorse', selected: false }) }   }    // CU already requested if (cuRequested && spiHelperIsClerk) { // Statuses only available if CU has been requested, only clerks + CUs should use these selectOpts.push({ label: 'Endorse for CU attention', value: 'endorse', selected: false }) // Switch the decline option depending on whether the user is a checkuser if (spiHelperIsCheckuser) { selectOpts.push({ label: 'Endorse CU as a CheckUser', value: 'cuendorse', selected: false }) }     if (spiHelperIsCheckuser) { selectOpts.push({ label: 'Decline CU', value: 'cudecline', selected: false }) } else { selectOpts.push({ label: 'Decline CU', value: 'decline', selected: false }) }     selectOpts.push({ label: 'Request more information for CU', value: 'cumoreinfo', selected: false }) } else if (cuEndorsed && spiHelperIsCheckuser) { // Let checkusers decline endorsed cases if (spiHelperIsCheckuser) { selectOpts.push({ label: 'Decline CU', value: 'cudecline', selected: false }) }     selectOpts.push({ label: 'Request more information for CU', value: 'cumoreinfo', selected: false }) }   // This is mostly a CU function, but let's let clerks and admins set it    //  in case the CU forgot (or in case we're un-closing))    if (spiHelperIsAdmin || spiHelperIsClerk) {      selectOpts.push({ label: 'Mark as checked', value: 'checked', selected: false })    }    if (spiHelperIsClerk && cuCompleted) {      selectOpts.push({ label: 'Relist for another check', value: 'relist', selected: false })    }    if (spiHelperIsCheckuser) {      selectOpts.push({ label: 'Place case on CU hold', value: 'cuhold', selected: false })    } else { // I guess it's okay for anyone to have this option      selectOpts.push({ label: 'Place case on hold', value: 'hold', selected: false })    }    selectOpts.push({ label: 'Request clerk action', value: 'clerk', selected: false })    // I think this is only useful for non-admin clerks to ask admins to do stuff    if (!spiHelperIsAdmin && spiHelperIsClerk) { selectOpts.push({ label: 'Request admin action', value: 'admin', selected: false }) }   // Generate the case action options spiHelperGenerateSelect('spiHelper_CaseAction', selectOpts) // Add the onclick handler to the drop-down $('#spiHelper_CaseAction', $actionView).on('change', function (e) {     spiHelperCaseActionUpdated($(e.target))    }) } else { $('#spiHelper_actionView', $actionView).hide }

if (spiHelperActionsSelected.SpiMgmt) { const $xwikiBox = $('#spiHelper_spiMgmt_crosswiki', $actionView) const $denyBox = $('#spiHelper_spiMgmt_deny', $actionView)

$xwikiBox.prop('checked', spiHelperArchiveNoticeParams.xwiki) $denyBox.prop('checked', spiHelperArchiveNoticeParams.deny) } else { $('#spiHelper_spiMgmtView', $actionView).hide }

if (spiHelperActionsSelected.Block) { if (spiHelperIsAdmin) { $('#spiHelper_blockTagHeader', $actionView).text('Blocking and tagging socks') } else { $('#spiHelper_blockTagHeader', $actionView).text('Tagging socks') }   // eslint-disable-next-line no-useless-escape const checkuserRegex = /{{\s*check(?:user|ip)\s*\|\s*(?:1=)?\s*([^\|}]*?)\s*(?:\|master name\s*=\s*.*)?}}/gi const results = pagetext.match(checkuserRegex) const likelyusers = [] const likelyips = [] const possibleusers = [] const possibleips = [] likelyusers.push(spiHelperCaseName) if (results) { for (let i = 0; i < results.length; i++) { const username = spiHelperNormalizeUsername(results[i].replace(checkuserRegex, '$1')) const isIP = mw.util.isIPAddress(username, true) if (!isIP && !likelyusers.includes(username)) { likelyusers.push(username) } else if (isIP && !likelyips.includes(username)) { likelyips.push(username) }     }    }    // eslint-disable-next-line no-useless-escape const userRegex = /{{\s*(?:user|vandal|IP|noping|noping2)[^\|}{]*?\s*\|\s*(?:1=)?\s*([^\|}]*?)\s*}}/gi const userresults = pagetext.match(userRegex) if (userresults) { for (let i = 0; i < userresults.length; i++) { const username = spiHelperNormalizeUsername(userresults[i].replace(userRegex, '$1')) if (mw.util.isIPAddress(username, true) && !possibleips.includes(username) &&         !likelyips.includes(username)) { possibleips.push(username) } else if (!possibleusers.includes(username) &&         !likelyusers.includes(username)) { possibleusers.push(username) }     }    }    // Wire up the "select all" options $('#spiHelper_block_doblock', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_acb', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_ab', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_tp', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_email', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_lock', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_lock', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) spiHelperGenerateSelect('spiHelper_block_tag', spiHelperTagOptions) $('#spiHelper_block_tag', $actionView).on('change', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) spiHelperGenerateSelect('spiHelper_block_tag_altmaster', spiHelperAltMasterTagOptions) $('#spiHelper_block_tag_altmaster', $actionView).on('change', function (e) {     spiHelperSetAllBlockOpts($(e.target))    }) $('#spiHelper_block_lock', $actionView).on('click', function (e) {     spiHelperSetAllBlockOpts($(e.target))    })

for (let i = 0; i < likelyusers.length; i++) { spiHelperUserCount++ spiHelperGenerateBlockTableLine(likelyusers[i], true, spiHelperUserCount) }   for (let i = 0; i < likelyips.length; i++) { spiHelperUserCount++ spiHelperGenerateBlockTableLine(likelyips[i], true, spiHelperUserCount) }   for (let i = 0; i < possibleusers.length; i++) { spiHelperUserCount++ spiHelperGenerateBlockTableLine(possibleusers[i], false, spiHelperUserCount) }   for (let i = 0; i < possibleips.length; i++) { spiHelperUserCount++ spiHelperGenerateBlockTableLine(possibleips[i], false, spiHelperUserCount) } } else { $('#spiHelper_blockTagView', $actionView).hide } if (!spiHelperActionsSelected.Close) { $('#spiHelper_closeView', $actionView).hide } if (spiHelperActionsSelected.Rename) { if (spiHelperSectionId) { $('#spiHelper_moveHeader', $actionView).text('Move section "' + spiHelperSectionName + '"') } else { $('#spiHelper_moveHeader', $actionView).text('Move/merge full case') } } else { $('#spiHelper_moveView', $actionView).hide }

if (!spiHelperActionsSelected.Archive) { $('#spiHelper_archiveView', $actionView).hide }

// Only give the option to comment if we selected a specific section if (spiHelperSectionId) { // generate the note prefixes /** @type {SelectOption[]} */ const spiHelperNoteTemplates = [ { label: 'Comment templates', selected: true, value: '', disabled: true } ]   if (spiHelperIsClerk) { spiHelperNoteTemplates.push({ label: 'Clerk note', selected: false, value: 'clerknote' }) }   if (spiHelperIsAdmin) { spiHelperNoteTemplates.push({ label: 'Administrator note', selected: false, value: 'adminnote' }) }   if (spiHelperIsCheckuser) { spiHelperNoteTemplates.push({ label: 'CU note', selected: false, value: 'cunote' }) }   spiHelperNoteTemplates.push({ label: 'Note', selected: false, value: 'takenote' })

// Wire up the select boxes spiHelperGenerateSelect('spiHelper_noteSelect', spiHelperNoteTemplates) $('#spiHelper_noteSelect', $actionView).on('change', function (e) {     spiHelperInsertNote($(e.target))    }) spiHelperGenerateSelect('spiHelper_adminSelect', spiHelperAdminTemplates) $('#spiHelper_adminSelect', $actionView).on('change', function (e) {     spiHelperInsertTextFromSelect($(e.target))    }) spiHelperGenerateSelect('spiHelper_cuSelect', spiHelperCUTemplates) $('#spiHelper_cuSelect', $actionView).on('change', function (e) {     spiHelperInsertTextFromSelect($(e.target))    }) $('#spiHelper_previewLink', $actionView).on('click', function {      spiHelperPreviewText    }) } else { $('#spiHelper_commentView', $actionView).hide } // Wire up the submit button $('#spiHelper_performActions', $actionView).on('click', => {    spiHelperPerformActions  })

// Hide items based on role if (!spiHelperIsCheckuser) { // Hide CU options from non-CUs $('.spiHelper_cuClass', $actionView).hide } if (!spiHelperIsAdmin) { // Hide block options from non-admins $('.spiHelper_adminClass', $actionView).hide } if (!(spiHelperIsAdmin || spiHelperIsClerk)) { $('.spiHelper_adminClerkClass', $actionView).hide } }

/** * Archives everything on the page that's eligible for archiving */ async function spiHelperOneClickArchive { 'use strict' spiHelperActiveOperations.set('oneClickArchive', 'running')

const pagetext = await spiHelperGetPageText(spiHelperPageName, false) spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs if (!spiHelperSectionRegex.test(pagetext)) { alert('Looks like the page has been archived already.') spiHelperActiveOperations.set('oneClickArchive', 'successful') return } displayMessage('<ul id="spiHelper_status"/>') await spiHelperArchiveCase await spiHelperPurgePage(spiHelperPageName) const logMessage = '* ' + spiHelperPageName + ': used one-click archiver ' if (spiHelperSettings.log) { spiHelperLog(logMessage) } $('#spiHelper_status', document).append($('').text('Done!')) spiHelperActiveOperations.set('oneClickArchive', 'successful') }

/** * Another "meaty" function - goes through the action selections and executes them */ async function spiHelperPerformActions { 'use strict' spiHelperActiveOperations.set('mainActions', 'running')

// Again, reduce the search scope const $actionView = $('#spiHelper_actionViewDiv', document)

// set up a few function-scoped vars let comment = '' let cuBlock = false let cuBlockOnly = false let newCaseStatus = 'noaction' let renameTarget = ''

/** @type {boolean} */ const blankTalk = $('#spiHelper_blanktalk', $actionView).prop('checked') /** @type {boolean} */ const overrideExisting = $('#spiHelper_override', $actionView).prop('checked') /** @type {boolean} */ const hideLockNames = $('#spiHelper_hidelocknames', $actionView).prop('checked')

if (spiHelperActionsSelected.Case_act) { newCaseStatus = $('#spiHelper_CaseAction', $actionView).val.toString } if (spiHelperActionsSelected.SpiMgmt) { spiHelperArchiveNoticeParams.deny = $('#spiHelper_spiMgmt_deny', $actionView).prop('checked') spiHelperArchiveNoticeParams.xwiki = $('#spiHelper_spiMgmt_crosswiki', $actionView).prop('checked') } if (spiHelperSectionId) { comment = $('#spiHelper_CommentText', $actionView).val.toString } if (spiHelperActionsSelected.Block) { if (spiHelperIsCheckuser) { cuBlock = $('#spiHelper_cublock', $actionView).prop('checked') cuBlockOnly = $('#spiHelper_cublockonly', $actionView).prop('checked') }   if (spiHelperIsAdmin && !$('#spiHelper_noblock', $actionView).prop('checked')) { const masterNotice = $('#spiHelper_blocknoticemaster', $actionView).prop('checked') const sockNotice = $('#spiHelper_blocknoticesocks', $actionView).prop('checked') for (let i = 1; i <= spiHelperUserCount; i++) { if ($('#spiHelper_block_doblock' + i, $actionView).prop('checked')) { if (!$('#spiHelper_block_username' + i, $actionView).val.toString) { // Skip blank usernames, empty string is falsey continue }         let noticetype = ''

if (masterNotice && $('#spiHelper_block_tag' + i, $actionView).val.toString.includes('master')) { noticetype = 'master' } else if (sockNotice) { noticetype = 'sock' }

/** @type {BlockEntry} */ const item = { username: spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val.toString), duration: $('#spiHelper_block_duration' + i, $actionView).val.toString, acb: $('#spiHelper_block_acb' + i, $actionView).prop('checked'), ab: $('#spiHelper_block_ab' + i, $actionView).prop('checked'), ntp: $('#spiHelper_block_tp' + i, $actionView).prop('checked'), nem: $('#spiHelper_block_email' + i, $actionView).prop('checked'), tpn: noticetype }

spiHelperBlocks.push(item) }       if ($('#spiHelper_block_lock' + i, $actionView).prop('checked')) { spiHelperGlobalLocks.push($('#spiHelper_block_username' + i, $actionView).val.toString) }       if ($('#spiHelper_block_tag' + i).val !== '') { if (!$('#spiHelper_block_username' + i, $actionView).val.toString) { // Skip blank entries continue }         const item = { username: spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val.toString), tag: $('#spiHelper_block_tag' + i, $actionView).val.toString, altmasterTag: $('#spiHelper_block_tag_altmaster' + i, $actionView).val.toString, blocking: $('#spiHelper_block_doblock' + i, $actionView).prop('checked') }         spiHelperTags.push(item) }     }    } else { for (let i = 1; i <= spiHelperUserCount; i++) { if (!$('#spiHelper_block_username' + i, $actionView).val.toString) { // Skip blank entries continue }       if ($('#spiHelper_block_tag' + i, $actionView).val !== '') { const item = { username: spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val.toString), tag: $('#spiHelper_block_tag' + i, $actionView).val.toString, altmasterTag: $('#spiHelper_block_tag_altmaster' + i, $actionView).val.toString, blocking: false }         spiHelperTags.push(item) }       if ($('#spiHelper_block_lock' + i, $actionView).prop('checked')) { spiHelperGlobalLocks.push(spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val.toString)) }     }    }  }  if (spiHelperActionsSelected.Close) { spiHelperActionsSelected.Close = $('#spiHelper_CloseCase', $actionView).prop('checked') } if (spiHelperActionsSelected.Rename) { renameTarget = spiHelperNormalizeUsername($('#spiHelper_moveTarget', $actionView).val.toString) } if (spiHelperActionsSelected.Archive) { spiHelperActionsSelected.Archive = $('#spiHelper_ArchiveCase', $actionView).prop('checked') }

displayMessage('<ul id="spiHelper_status" />')

const $statusAnchor = $('#spiHelper_status', document)

let sectionText = await spiHelperGetPageText(spiHelperPageName, true, spiHelperSectionId) let editsummary = '' let logMessage = '* ' + spiHelperPageName + '' if (spiHelperSectionId) { logMessage += ' (section ' + spiHelperSectionName + ')' } else { logMessage += ' (full case)' } logMessage += ' '

if (spiHelperSectionId !== null) { let caseStatusResult = spiHelperCaseStatusRegex.exec(sectionText) if (caseStatusResult === null) { sectionText = sectionText.replace('===', '\n===') caseStatusResult = spiHelperCaseStatusRegex.exec(sectionText) }   const oldCaseStatus = caseStatusResult[1] || 'open' if (newCaseStatus === 'noaction') { newCaseStatus = oldCaseStatus }

if (spiHelperActionsSelected.Case_act && newCaseStatus !== 'noaction') { switch (newCaseStatus) { case 'reopen': newCaseStatus = 'open' editsummary = 'Reopening' break case 'open': editsummary = 'Marking request as open' break case 'CUrequest': editsummary = 'Adding checkuser request' break case 'admin': editsummary = 'Requesting admin action' break case 'clerk': editsummary = 'Requesting clerk action' break case 'selfendorse': newCaseStatus = 'endorse' editsummary = 'Adding checkuser request (self-endorsed for checkuser attention)' break case 'checked': editsummary = 'Marking request as checked' break case 'inprogress': editsummary = 'Marking request in progress' break case 'decline': editsummary = 'Declining checkuser' break case 'cudecline': editsummary = 'CU declining checkuser' break case 'endorse': editsummary = 'Endorsing for checkuser attention' break case 'cuendorse': editsummary = 'CU endorsing for checkuser attention' break case 'moreinfo': // Intentional fallthrough case 'cumoreinfo': editsummary = 'Requesting additional information' break case 'relist': editsummary = 'Relisting case for another check' break case 'hold': editsummary = 'Putting case on hold' break case 'cuhold': editsummary = 'Placing checkuser request on hold' break case 'noaction': // Do nothing break default: console.error('Unexpected case status value ' + newCaseStatus) }     logMessage += '\n** changed case status from ' + oldCaseStatus + ' to ' + newCaseStatus } }

if (spiHelperActionsSelected.SpiMgmt) { const newArchiveNotice = spiHelperMakeNewArchiveNotice(spiHelperCaseName, spiHelperArchiveNoticeParams) sectionText = sectionText.replace(spiHelperArchiveNoticeRegex, newArchiveNotice) if (editsummary) { editsummary += ', update archivenotice' } else { editsummary = 'Update archivenotice' }   logMessage += '\n** Updated archivenotice' }

if (spiHelperActionsSelected.Block) { let sockmaster = '' let altmaster = '' let needsAltmaster = false spiHelperTags.forEach(async (tagEntry) => {     // we do not support tagging IPs      if (mw.util.isIPAddress(tagEntry.username, true)) {        // Skip, this is an IP        return      }      if (tagEntry.tag.includes('master')) {        sockmaster = tagEntry.username      }      if (tagEntry.altmasterTag !== '') {        needsAltmaster = true      }    }) if (sockmaster === '') { sockmaster = prompt('Please enter the name of the sockmaster: ', spiHelperCaseName) || spiHelperCaseName }   if (needsAltmaster) { altmaster = prompt('Please enter the name of the alternate sockmaster: ', spiHelperCaseName) || spiHelperCaseName }

let blockedList = '' if (spiHelperIsAdmin) { spiHelperBlocks.forEach(async (blockEntry) => {       const blockReason = await spiHelperGetUserBlockReason(blockEntry.username)        if (!spiHelperIsCheckuser && overrideExisting && spiHelperCUBlockRegex.exec(blockReason)) {         // If you're not a checkuser, we've asked to overwrite existing blocks, and the block          // target has a CU block on them, check whether that was intended          if (!confirm('User ' + blockEntry.username + ' appears to be CheckUser-blocked, are you SURE you want to re-block them?\n' +            'Current block message:\n' + blockReason          )) {            return          }        }        const isIP = mw.util.isIPAddress(blockEntry.username, true)        const isIPRange = isIP && !mw.util.isIPAddress(blockEntry.username, false)        let blockSummary = 'Abusing multiple accounts: Please see: ' + spiHelperInterwikiPrefix + spiHelperPageName +         if (spiHelperIsCheckuser && cuBlock) {          const cublockTemplate = isIP ? () : ('')          if (cuBlockOnly) {            blockSummary = cublockTemplate } else { blockSummary = cublockTemplate + ': ' + blockSummary }       } else if (isIPRange) { blockSummary = '' }       const blockSuccess = await spiHelperBlockUser(          blockEntry.username,          blockEntry.duration,          blockSummary,          overrideExisting,          (isIP ? blockEntry.ab : false),         blockEntry.acb,          (isIP ? false : blockEntry.ab),         blockEntry.ntp,          blockEntry.nem,          spiHelperSettings.watchBlockedUser,          spiHelperSettings.watchBlockedUserExpiry) if (!blockSuccess) { // Don't add a block notice if we failed to block if (blockEntry.tpn) { // Also warn the user if we were going to post a block notice on their talk page const $statusLine = $('').appendTo($('#spiHelper_status', document)) $statusLine.addClass('spiHelper-errortext').html('Block failed on ' + blockEntry.username + ', not adding talk page notice') }         return }       if (blockedList) { blockedList += ', ' }       blockedList += ''

if (isIPRange) { // There isn't really a talk page for an IP range, so return here before we reach that section return }       // Talk page notice if (blockEntry.tpn) { let newText = '' let isSock = blockEntry.tpn.includes('sock') // Hacky workaround for when we didn't make a master tag if (isSock && blockEntry.username === sockmaster) { isSock = false }         if (isSock) { newText = '== Blocked as a sockpuppet ==\n' } else { newText = '== Blocked for sockpuppetry ==\n' }         newText += ''

if (!blankTalk) { const oldtext = await spiHelperGetPageText('User talk:' + blockEntry.username, true) if (oldtext !== '') { newText = oldtext + '\n' + newText }         }          // Hardcode the watch setting to 'nochange' since we will have either watched or not watched based on the _boolean_ // watchBlockedUser spiHelperEditPage('User talk:' + blockEntry.username,           newText, 'Adding sockpuppetry block notice per ' + spiHelperGetInterwikiPrefix + spiHelperPageName + '', false, 'nochange') }     })    }    if (blockedList) {      logMessage += '\n** blocked ' + blockedList    }

let tagged = '' if (sockmaster) { // Whether we should purge sock pages (needed when we create a category) let needsPurge = false // True for each we need to check if the respective category (e.g.     // "Suspected sockpuppets of Test") exists let checkConfirmedCat = false let checkSuspectedCat = false let checkAltSuspectedCat = false let checkAltConfirmedCat = false spiHelperTags.forEach(async (tagEntry) => {       if (mw.util.isIPAddress(tagEntry.username, true)) {          return // do not support tagging IPs        }        let tagText =         let altmasterName =         let altmasterTag =         if (altmaster !==  && tagEntry.altmasterTag !== ) {          altmasterName = altmaster          altmasterTag = tagEntry.altmasterTag          switch (altmasterTag) {            case 'suspected':              checkAltSuspectedCat = true              break            case 'proven':              checkAltConfirmedCat = true              break          }        }        let isMaster = false        let tag =         let checked = ''        switch (tagEntry.tag) {          case 'blocked':            tag = 'blocked'            checkSuspectedCat = true            break          case 'proven':            tag = 'proven'            checkConfirmedCat = true break case 'confirmed': tag = 'confirmed' checkConfirmedCat = true break case 'master': tag = 'blocked' isMaster = true break case 'sockmasterchecked': tag = 'blocked' checked = 'yes' isMaster = true break case 'bannedmaster': tag = 'banned' checked = 'yes' isMaster = true break }       const isLocked = await spiHelperIsUserGloballyLocked(tagEntry.username) ? 'yes' : 'no' let isNotBlocked // If this account is going to be blocked, force isNotBlocked to 'no' - it's possible that the // block hasn't gone through by the time we reach this point if (tagEntry.blocking) { isNotBlocked = 'no' } else { // Otherwise, query whether the user is blocked isNotBlocked = await spiHelperGetUserBlockReason(tagEntry.username) ? 'no' : 'yes' }       if (isMaster) { // Not doing SPI or LTA fields for now - those auto-detect right now // and I'm not sure if setting them to empty would mess that up         tagText += `` }       // Not if-else because we tag something as both sock and master if they're a        // sockmaster and have a suspected altmaster if (!isMaster || altmasterName) { let sockmasterName = sockmaster if (altmasterName && isMaster) { // If we have an altmaster and we're the master, swap a few values around sockmasterName = altmasterName tag = altmasterTag altmasterName = '' altmasterTag = '' tagText += '\n' }         tagText += `` }       spiHelperEditPage('User:' + tagEntry.username, tagText, 'Adding sockpuppetry tag per ' + spiHelperGetInterwikiPrefix + spiHelperPageName + '',          false, spiHelperSettings.watchTaggedUser, spiHelperSettings.watchTaggedUserExpiry) if (tagged) { tagged += ', ' }       tagged += '' })     if (tagged) {        logMessage += '\n** tagged ' + tagged      }

if (checkAltConfirmedCat) { const catname = 'Category:FAMEPedia sockpuppets of ' + altmaster const cattext = await spiHelperGetPageText(catname, false) // Empty text means the page doesn't exist - create it       if (!cattext) { await spiHelperEditPage(catname, ,           'Creating sockpuppet category per ' + spiHelperGetInterwikiPrefix + spiHelperPageName + ,            true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry) needsPurge = true }     }      if (checkAltSuspectedCat) { const catname = 'Category:Suspected FAMEPedia sockpuppets of ' + altmaster const cattext = await spiHelperGetPageText(catname, false) if (!cattext) { await spiHelperEditPage(catname, ,           'Creating sockpuppet category per ' + spiHelperGetInterwikiPrefix + spiHelperPageName + ,            true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry) needsPurge = true }     }      if (checkConfirmedCat) { const catname = 'Category:FAMEPedia sockpuppets of ' + sockmaster const cattext = await spiHelperGetPageText(catname, false) if (!cattext) { await spiHelperEditPage(catname, ,           'Creating sockpuppet category per ' + spiHelperGetInterwikiPrefix + spiHelperPageName + ,            true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry) needsPurge = true }     }      if (checkSuspectedCat) { const catname = 'Category:Suspected FAMEPedia sockpuppets of ' + sockmaster const cattext = await spiHelperGetPageText(catname, false) if (!cattext) { await spiHelperEditPage(catname, ,           'Creating sockpuppet category per ' + spiHelperGetInterwikiPrefix + spiHelperPageName + ,            true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry) needsPurge = true }     }      // Purge the sock pages if we created a category (to get rid of      // the issue where the page says "click here to create category"      // when the category was created after the page) if (needsPurge) { spiHelperTags.forEach((tagEntry) => {         if (mw.util.isIPAddress(tagEntry.username, true)) {            // Skip, this is an IP            return          }          if (!tagEntry.tag && !tagEntry.altmasterTag) {            // Skip, not tagged            return          }          // Not bothering with an await, no need for async behavior here          spiHelperPurgePage('User:' + tagEntry.username)        }) }   }    if (spiHelperGlobalLocks.length > 0) { let locked = '' let templateContent = '' let matchCount = 0 spiHelperGlobalLocks.forEach(async (globalLockEntry) => {       // do not support locking IPs (those are global blocks, not // locks, and are handled a bit differently)       if (mw.util.isIPAddress(globalLockEntry, true)) {          return        }        templateContent += '|' + globalLockEntry        if (locked) {          locked += ', '        }        locked += ''        matchCount++      })

if (matchCount > 0) { if (hideLockNames) { // If requested, hide locked names templateContent += '|hidename=1' }       // Parts of this code were adapted from https://github.com/Xi-Plus/twinkle-global let lockTemplate = '' if (matchCount === 1) { lockTemplate = '* ' } else { lockTemplate = '* ' }       if (!sockmaster) { sockmaster = prompt('Please enter the name of the sockmaster: ', spiHelperCaseName) || spiHelperCaseName }       const lockComment = prompt('Please enter a comment for the global lock request (optional):', ) ||  const heading = hideLockNames ? 'sockpuppet(s)' : '' + sockmaster + ' sock(s)' let message = '=== Global lock for ' + heading + ' ===' message += '\n' message += '\n' + lockTemplate message += '\n* Sockpuppet(s) found in enwiki sockpuppet investigation, see ' + spiHelperInterwikiPrefix + spiHelperPageName + '. ' + lockComment + ' ~'

// Write lock request to Stewards' noticeboard let srgText = await spiHelperGetPageText('meta:Stewards%27 noticeboard', false) srgText = srgText.replace(/\n+(== See also == *\n)/, '\n\n' + message + '\n\n$1') spiHelperEditPage('meta:Stewards%27 noticeboard', srgText, 'global lock request for ' + heading, false, 'nochange') $statusAnchor.append($('<li>').text('Filing global lock request')) }     if (locked) { logMessage += '\n** requested locks for ' + locked }   }  }  if (spiHelperSectionId && comment && comment !== '*') { if (!sectionText.includes('\n')) { sectionText += '\n' }   if (!/~/.test(comment)) { comment += ' ~' }   // Clerks and admins post in the admin section if (spiHelperIsClerk || spiHelperIsAdmin) { // Complicated regex to find the first regex in the admin section // The weird (\n|.) is because we can't use /s (dot matches newline) regex mode without ES9, // I don't want to go there yet sectionText = sectionText.replace(/\n*(?!(\n|.)*)/, '\n' + comment + '\n') } else { // Everyone else posts in the "other users" section sectionText = sectionText.replace(spiHelperAdminSectionWithPrecedingNewlinesRegex,       '\n' + comment + '\n==== Clerk, CheckUser, and/or patrolling admin comments ====\n') }   if (editsummary) { editsummary += ', comment' } else { editsummary = 'Comment' }   logMessage += '\n** commented' }

if (spiHelperActionsSelected.Close) { newCaseStatus = 'close' if (editsummary) { editsummary += ', marking case as closed' } else { editsummary = 'Marking case as closed' }   logMessage += '\n** closed case' } if (spiHelperSectionId !== null) { const caseStatusText = spiHelperCaseStatusRegex.exec(sectionText)[0] sectionText = sectionText.replace(caseStatusText, '') }

// Fallback: if we somehow managed to not make an edit summary, add a default one if (!editsummary) { editsummary = 'Saving page' }

// Make all of the requested edits (synchronous since we might make more changes to the page) await spiHelperEditPage(spiHelperPageName, sectionText, editsummary, false,   spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID, spiHelperSectionId) // Update to the latest revision ID spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName) if (spiHelperActionsSelected.Archive) { // Archive the case if (spiHelperSectionId === null) { // Archive the whole case logMessage += '\n** Archived case' await spiHelperArchiveCase } else { // Just archive the selected section logMessage += '\n** Archived section' await spiHelperArchiveCaseSection(spiHelperSectionId) } } else if (spiHelperActionsSelected.Rename && renameTarget) { if (spiHelperSectionId === null) { // Option 1: we selected "All cases," this is a whole-case move/merge logMessage += '\n** moved/merged case to ' + renameTarget await spiHelperMoveCase(renameTarget) } else { // Option 2: this is a single-section case move or merge logMessage += '\n** moved section to ' + renameTarget await spiHelperMoveCaseSection(renameTarget, spiHelperSectionId) } }  if (spiHelperSettings.log) { spiHelperLog(logMessage) }

await spiHelperPurgePage(spiHelperPageName) $('#spiHelper_status', document).append($('<li>').text('Done!')) spiHelperActiveOperations.set('mainActions', 'successful') }

/** * Logs SPI actions to userspace a la Twinkle's CSD/prod/etc. logs * * @param {string} logString String with the changes the user made */ async function spiHelperLog (logString) { const now = new Date const dateString = now.toLocaleString('en', { month: 'long' }) + ' ' + now.toLocaleString('en', { year: 'numeric' }) const dateHeader = '==\\s*' + dateString + '\\s*==' const dateHeaderRe = new RegExp(dateHeader, 'i') const dateHeaderReWithAnyDate = /==.*?==/i

let logPageText = await spiHelperGetPageText('User:' + mw.config.get('wgUserName') + '/spihelper_log', false) if (!logPageText.match(dateHeaderRe)) { if (spiHelperSettings.reversed_log) { const firstHeaderMatch = logPageText.match(dateHeaderReWithAnyDate) logPageText = logPageText.substring(0, firstHeaderMatch.index) + '== ' + dateString + ' ==\n' + logPageText.substring(firstHeaderMatch.index) } else { logPageText += '\n== ' + dateString + ' ==' } }  if (spiHelperSettings.reversed_log) { const firstHeaderMatch = logPageText.match(dateHeaderReWithAnyDate) logPageText = logPageText.substring(0, firstHeaderMatch.index + firstHeaderMatch[0].length) + '\n' + logString + logPageText.substring(firstHeaderMatch.index + firstHeaderMatch[0].length) } else { logPageText += '\n' + logString } await spiHelperEditPage('User:' + mw.config.get('wgUserName') + '/spihelper_log', logPageText, 'Logging spihelper edits', false, 'nochange') }

// Major helper functions /** * Cleanups following a rename - update the archive notice, add an archive notice to the * old case name, add the original sockmaster to the sock list for reference * * @param {string} oldCasePage Title of the previous case page */ async function spiHelperPostRenameCleanup (oldCasePage) { 'use strict' const replacementArchiveNotice = ' \n' + spiHelperMakeNewArchiveNotice(spiHelperCaseName, spiHelperArchiveNoticeParams) + '\n' const oldCaseName = oldCasePage.replace(/FAMEPedia:Sockpuppet investigations\//g, '')

// The old case should just be the archivenotice template and point to the new case spiHelperEditPage(oldCasePage, replacementArchiveNotice, 'Updating case following page move', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)

// The new case's archivenotice should be updated with the new name let newPageText = await spiHelperGetPageText(spiHelperPageName, true) newPageText = newPageText.replace(spiHelperArchiveNoticeRegex, '') // We also want to add the previous master to the sock list // We use SOCK_SECTION_RE_WITH_NEWLINE to clean up any extraneous whitespace newPageText = newPageText.replace(spiHelperSockSectionWithNewlineRegex, '====Suspected sockpuppets====' +   '\n*  ( original case name)\n') // Also remove the new master if they're in the sock list // This RE is kind of ugly. The idea is that we find everything from the level 4 heading // ending with "sockpuppets" to the level 4 heading beginning with and pull the checkuser // template matching the current case name out. This keeps us from accidentally replacing a // checkuser entry in the admin section const newMasterReString = '(sockpuppets\\s*====.*?)\\n^\\s*\\*\\s*\\s*$(.*====\\s* )' const newMasterRe = new RegExp(newMasterReString, 'sm') newPageText = newPageText.replace(newMasterRe, '$1\n$2')

await spiHelperEditPage(spiHelperPageName, newPageText, 'Updating case following page move', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID) // Update to the latest revision ID spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName) }

/** * Cleanups following a merge - re-insert the original page text * * @param {string} originalText Text of the page pre-merge */ async function spiHelperPostMergeCleanup (originalText) { 'use strict' let newText = await spiHelperGetPageText(spiHelperPageName, false) // Remove the SPI header templates from the page newText = newText.replace(/\n* .*\n/ig, '') newText = newText.replace(spiHelperArchiveNoticeRegex, '') newText = newText.replace(spiHelperPriorCasesRegex, '') newText = originalText + '\n' + newText

// Write the updated case await spiHelperEditPage(spiHelperPageName, newText, 'Re-adding previous cases following merge', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID) // Update to the latest revision ID spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName) }

/** * Archive all closed sections of a case */ async function spiHelperArchiveCase { 'use strict' let i = 0 let previousRev = 0 while (i < spiHelperCaseSections.length) { const sectionId = spiHelperCaseSections[i].index const sectionText = await spiHelperGetPageText(spiHelperPageName, false,     sectionId)

const currentRev = await spiHelperGetPageRev(spiHelperPageName) if (previousRev === currentRev && currentRev !== 0) { // Our previous archive hasn't gone through yet, wait a bit and retry await new Promise((resolve) => {       setTimeout(resolve, 100)      })

// Re-grab the case sections list since the page may have updated spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs continue }   previousRev = await spiHelperGetPageRev(spiHelperPageName) i++ const result = spiHelperCaseStatusRegex.exec(sectionText) if (result === null) { // Bail out - can't find the case status template in this section continue }   if (spiHelperCaseClosedRegex.test(result[1])) { // A running concern with the SPI archives is whether they exceed the post-expand // include size. Calculate what percent of that size the archive will be if we     // add the current page to it - if >1, we need to archive the archive const postExpandPercent = (await spiHelperGetPostExpandSize(spiHelperPageName, sectionId) +       await spiHelperGetPostExpandSize(spiHelperGetArchiveName)) / spiHelperGetMaxPostExpandSize if (postExpandPercent >= 1) { // We'd overflow the archive, so move it and then archive the current page // Find the first empty archive page let archiveId = 1 while (await spiHelperGetPageText(spiHelperGetArchiveName + '/' + archiveId, false) !== '') { archiveId++ }       const newArchiveName = spiHelperGetArchiveName + '/' + archiveId await spiHelperMovePage(spiHelperGetArchiveName, newArchiveName, 'Moving archive to avoid exceeding post expand size limit', false) await spiHelperEditPage(spiHelperGetArchiveName, '', 'Removing redirect', false, 'nochange') }     // Need an await here - if we have multiple sections archiving we don't want // to stomp on each other await spiHelperArchiveCaseSection(sectionId) // need to re-fetch caseSections since the section numbering probably just changed, // also reset our index i = 0 spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs } } }

/** * Archive a specific section of a case * * @param {!number} sectionId The section number to archive */ async function spiHelperArchiveCaseSection (sectionId) { 'use strict' let sectionText = await spiHelperGetPageText(spiHelperPageName, true, sectionId) sectionText = sectionText.replace(spiHelperCaseStatusRegex, '') const newarchivetext = sectionText.substring(sectionText.search(spiHelperSectionRegex))

// Update the archive let archivetext = await spiHelperGetPageText(spiHelperGetArchiveName, true) if (!archivetext) { archivetext = '\n\n' } else { archivetext = archivetext.replace(/<br\s*\/>\s*/gi, '\n') // fmt fix whenever needed. } archivetext += '\n' + newarchivetext const archiveSuccess = await spiHelperEditPage(spiHelperGetArchiveName, archivetext,   'Archiving case section from ' + spiHelperGetInterwikiPrefix + spiHelperPageName + '',    false, spiHelperSettings.watchArchive, spiHelperSettings.watchArchiveExpiry)

if (!archiveSuccess) { const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) $statusLine.addClass('spiHelper-errortext').append('b').text('Failed to update archive, not removing section from case page') return }

// Blank the section we archived await spiHelperEditPage(spiHelperPageName, , 'Archiving case section to ' + spiHelperGetInterwikiPrefix + spiHelperGetArchiveName + ,   false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID, sectionId) // Update to the latest revision ID spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName) }

/** * Move or merge the selected case into a different case * * @param {string} target The username portion of the case this section should be merged into *                       (should have been normalized before getting passed in) */ async function spiHelperMoveCase (target) { // Move or merge an entire case // Normalize: change underscores to spaces // target = target const newPageName = spiHelperPageName.replace(spiHelperCaseName, target) const targetPageText = await spiHelperGetPageText(newPageName, false) if (targetPageText) { if (spiHelperIsAdmin) { const proceed = confirm('Target page exists, do you want to histmerge the cases?') if (!proceed) { // Build out the error line $('<li>') .append($(' ').addClass('spihelper-errortext')           .append($('<b>').text('Aborted merge.'))) .appendTo($('#spiHelper_status', document)) return }   } else { $('<li>') .append($(' ').addClass('spihelper-errortext')         .append($('<b>').text('Target page exists and you are not an admin, aborting merge.'))) .appendTo($('#spiHelper_status', document)) return } }  // Housekeeping to update all of the var names following the rename const oldPageName = spiHelperPageName const oldArchiveName = spiHelperGetArchiveName spiHelperCaseName = target spiHelperPageName = newPageName let archivesCopied = false if (targetPageText) { // There's already a page there, we're going to merge // First, check if there's an archive; if so, copy its text over const newArchiveName = spiHelperGetArchiveName.replace(spiHelperCaseName, target) let sourceArchiveText = await spiHelperGetPageText(oldArchiveName, false) let targetArchiveText = await spiHelperGetPageText(newArchiveName, false) if (sourceArchiveText && targetArchiveText) { $('<li>') .append($(' ').text('Archive detected on both source and target cases, manually copying archive.')) .appendTo($('#spiHelper_status', document))

// Normalize the source archive text sourceArchiveText = sourceArchiveText.replace(/^\s*\s*$\n/gm, '') sourceArchiveText = sourceArchiveText.replace(spiHelperArchiveNoticeRegex, '') sourceArchiveText = sourceArchiveText.replace(spiHelperPriorCasesRegex, '') // Strip leading newlines sourceArchiveText = sourceArchiveText.replace(/^\n*/, '') targetArchiveText += '\n' + sourceArchiveText await spiHelperEditPage(newArchiveName, targetArchiveText, 'Copying archives from ' + spiHelperGetInterwikiPrefix + oldArchiveName + ', see page history for attribution',       false, spiHelperSettings.watchArchive, spiHelperSettings.watchArchiveExpiry) await spiHelperDeletePage(oldArchiveName, 'Deleting copied archive') archivesCopied = true }   // Ignore warnings on the move, we're going to get one since we're stomping an existing page await spiHelperDeletePage(spiHelperPageName, 'Deleting as part of case merge') await spiHelperMovePage(oldPageName, spiHelperPageName, 'Merging case to ' + spiHelperGetInterwikiPrefix + spiHelperPageName + '', true) await spiHelperUndeletePage(spiHelperPageName, 'Restoring page history after merge') if (archivesCopied) { // Create a redirect spiHelperEditPage(oldArchiveName, '#REDIRECT ' + newArchiveName + '', 'Redirecting old archive to new archive',       false, spiHelperSettings.watchArchive, spiHelperSettings.watchArchiveExpiry) } } else { await spiHelperMovePage(oldPageName, spiHelperPageName, 'Moving case to ' + spiHelperGetInterwikiPrefix + spiHelperPageName + '', false) } spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName) await spiHelperPostRenameCleanup(oldPageName) if (targetPageText) { // If there was a page there before, also need to do post-merge cleanup await spiHelperPostMergeCleanup(targetPageText) } if (archivesCopied) { alert('Archives were merged during the case move, please reorder the archive sections') } }

/** * Move or merge a specific section of a case into a different case * * @param {string} target The username portion of the case this section should be merged into (pre-normalized) * @param {!number} sectionId The section ID of this case that should be moved/merged */ async function spiHelperMoveCaseSection (target, sectionId) { // Move or merge a particular section of a case 'use strict' const newPageName = spiHelperPageName.replace(spiHelperCaseName, target) let targetPageText = await spiHelperGetPageText(newPageName, false) let sectionText = await spiHelperGetPageText(spiHelperPageName, true, sectionId) // SOCK_SECTION_RE_WITH_NEWLINE cleans up extraneous whitespace at the top of the section // Have to do this transform before concatenating with targetPageText so that the // "originally filed" goes in the correct section sectionText = sectionText.replace(spiHelperSockSectionWithNewlineRegex, '====Suspected sockpuppets====' + '\n*  ( originally filed under this user)\n')

if (targetPageText === '') { // Pre-load the split target with the SPI templates if it's empty targetPageText = ' \n\n' } targetPageText += '\n' + sectionText

// Intentionally not async - doesn't matter when this edit finishes spiHelperEditPage(newPageName, targetPageText, 'Moving case section from ' + spiHelperGetInterwikiPrefix + spiHelperPageName + ', see page history for attribution',   false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry) // Blank the section we moved await spiHelperEditPage(spiHelperPageName, , 'Moving case section to ' + spiHelperGetInterwikiPrefix + newPageName + ,   false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID, sectionId) // Update to the latest revision ID spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName) }

/** * Render a text box's contents and display it in the preview area * */ async function spiHelperPreviewText { const inputText = $('#spiHelper_CommentText', document).val.toString const renderedText = await spiHelperRenderText(spiHelperPageName, inputText) // Fill the preview box with the new text const $previewBox = $('#spiHelper_previewBox', document) $previewBox.html(renderedText) // Unhide it if it was hidden $previewBox.show }

/** * Given a page title, get an API to operate on that page * * @param {string} title Title of the page we want the API for * @return {Object} MediaWiki Api/ForeignAPI for the target page's wiki */ function spiHelperGetAPI (title) { 'use strict' if (title.startsWith('m:') || title.startsWith('meta:')) { return new mw.ForeignApi('https://meta.miraheze.org/w/api.php') } else { return new mw.Api } }

/** * Removes the interwiki prefix from a page title * * @param {*} title Page name including interwiki prefix * @return {string} Just the page name */ function spiHelperStripXWikiPrefix (title) { // TODO: This only works with single-colon names, make it more robust 'use strict' if (title.startsWith('m:') || title.startsWith('meta:')) { return title.slice(title.indexOf(':') + 1) } else { return title } }

/** * Get the post-expand include size of a given page * * @param {string} title Page title to check * @param {?number} sectionId Section to check, if null check the whole page * * @return {Promise } Post-expand include size of the given page/page section */ async function spiHelperGetPostExpandSize (title, sectionId = null) { // Synchronous method to get a page's post-expand include size given its title const finalTitle = spiHelperStripXWikiPrefix(title)

const request = { action: 'parse', prop: 'limitreportdata', page: finalTitle } if (sectionId) { request.section = sectionId } const api = spiHelperGetAPI(title) try { const response = await api.get(request)

// The page might not exist, so we need to handle that smartly - only get the parse // if the page actually parsed if ('parse' in response) { // Iterate over all properties to find the PEIS for (let i = 0; i < response.parse.limitreportdata.length; i++) { if (response.parse.limitreportdata[i].name === 'limitreport-postexpandincludesize') { return response.parse.limitreportdata[i][0] }     }    } else { // Fallback - most likely the page doesn't exist return 0 } } catch (error) { // Something's gone wrong, just return 0 return 0 } }

/** * Get the maximum post-expand size from the wgPageParseReport (it's the same for all pages) * * @return {number} The max post-expand size in bytes */ function spiHelperGetMaxPostExpandSize { 'use strict' return mw.config.get('wgPageParseReport').limitreport.postexpandincludesize.limit }

/** * Get the inter-wiki prefix for the current wiki * * @return {string} The inter-wiki prefix */ function spiHelperGetInterwikiPrefix { // Mostly copied from https://github.com/Xi-Plus/twinkle-global/blob/master/morebits.js // Most of this should be overkill (since most of these wikis don't have checkuser support) /** @type {string[]} */ const temp = mw.config.get('wgServer').replace(/^(https?)?\/\//, '').split('.') const wikiLang = temp[0] const wikiFamily = temp[1] switch (wikiFamily) { case 'miraheze': switch (wikiLang) { case 'commons': return ':commons:' case 'meta': return ':meta:' default: return '' }   case 'famedata': return ':d:' + wikiLang + ':' case 'famepedia': return ':f:' + wikiLang + ':' default: return '' } }

// "Building-block" functions to wrap basic API calls /** * Get the text of a page. Not that complicated. * * @param {string} title Title of the page to get the contents of * @param {boolean} show Whether to show page fetch progress on-screen * @param {?number} [sectionId=null] Section to retrieve, setting this to null will retrieve the entire page * * @return {Promise } The text of the page, '' if the page does not exist. */ async function spiHelperGetPageText (title, show, sectionId = null) { const $statusLine = $('<li>') if (show) { // Actually display the statusLine $('#spiHelper_status', document).append($statusLine) } // Build the link element (use JQuery so we get escapes and such) const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title) $statusLine.html('Getting page ' + $link.prop('outerHTML'))

const finalTitle = spiHelperStripXWikiPrefix(title)

const request = { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', indexpageids: true, titles: finalTitle }

if (sectionId) { request.rvsection = sectionId }

try { const response = await spiHelperGetAPI(title).get(request) const pageid = response.query.pageids[0]

if (pageid === '-1') { $statusLine.html('Page ' + $link.html + ' does not exist') return '' }   $statusLine.html('Got ' + $link.html) return response.query.pages[pageid].revisions[0].slots.main['*'] } catch (error) { $statusLine.addClass('spiHelper-errortext').html('Failed to get ' + $link.html + ': ' + error) return '' } }

/** * * @param {string} title Title of the page to edit * @param {string} newtext New content of the page * @param {string} summary Edit summary to use for the edit * @param {boolean} createonly Only try to create the page - if false, *                            will fail if the page already exists * @param {string} watch What watchlist setting to use when editing - decides *                      whether the edited page will be watched * @param {string} watchExpiry Duration to watch the edited page, if unset *                            defaults to 'indefinite' * @param {?number} baseRevId Base revision ID, used to detect edit conflicts. If null, *                          we'll grab the current page ID. * @param {?number} [sectionId=null] Section to edit - if null, edits the whole page * * @return {Promise } Whether the edit was successful */ async function spiHelperEditPage (title, newtext, summary, createonly, watch, watchExpiry = null, baseRevId = null, sectionId = null) { let activeOpKey = 'edit_' + title if (sectionId) { activeOpKey += '_' + sectionId } spiHelperActiveOperations.set(activeOpKey, 'running') const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)

$statusLine.html('Editing ' + $link.prop('outerHTML'))

if (!baseRevId) { baseRevId = await spiHelperGetPageRev(title) } const api = spiHelperGetAPI(title) const finalTitle = spiHelperStripXWikiPrefix(title)

const request = { action: 'edit', watchlist: watch, summary: summary + spihelperAdvert, text: newtext, title: finalTitle, createonly: createonly, baserevid: baseRevId } if (sectionId) { request.section = sectionId } if (watchExpiry) { request.watchlistExpiry = watchExpiry } try { await api.postWithToken('csrf', request) $statusLine.html('Saved ' + $link.prop('outerHTML')) spiHelperActiveOperations.set(activeOpKey, 'success') return true } catch (error) { $statusLine.addClass('spiHelper-errortext').html('Edit failed on ' + $link.html + ': ' + error) console.error(error) spiHelperActiveOperations.set(activeOpKey, 'failed') return false } } /** * Moves a page. Exactly what it sounds like. * * @param {string} sourcePage Title of the source page (page we're moving) * @param {string} destPage Title of the destination page (page we're moving to) * @param {string} summary Edit summary to use for the move * @param {boolean} ignoreWarnings Whether to ignore warnings on move (used to force-move one page over another) */ async function spiHelperMovePage (sourcePage, destPage, summary, ignoreWarnings) { // Move a page from sourcePage to destPage. Not that complicated. 'use strict'

const activeOpKey = 'move_' + sourcePage + '_' + destPage spiHelperActiveOperations.set(activeOpKey, 'running')

// Should never be a crosswiki call const api = new mw.Api

const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) const $sourceLink = $('<a>').attr('href', mw.util.getUrl(sourcePage)).attr('title', sourcePage).text(sourcePage) const $destLink = $('<a>').attr('href', mw.util.getUrl(destPage)).attr('title', destPage).text(destPage)

$statusLine.html('Moving ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML'))

try { await api.postWithToken('csrf', {     action: 'move',      from: sourcePage,      to: destPage,      reason: summary + spihelperAdvert,      noredirect: false,      movesubpages: true,      ignoreWarnings: ignoreWarnings    }) $statusLine.html('Moved ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML')) spiHelperActiveOperations.set(activeOpKey, 'success') } catch (error) { $statusLine.addClass('spihelper-errortext').html('Failed to move ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML') + ': ' + error) spiHelperActiveOperations.set(activeOpKey, 'failed') } }

/** * Purges a page's cache * * * @param {string} title Title of the page to purge */ async function spiHelperPurgePage (title) { // Forces a cache purge on the selected page 'use strict' const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title) $statusLine.html('Purging ' + $link.prop('outerHTML')) const strippedTitle = spiHelperStripXWikiPrefix(title)

const api = spiHelperGetAPI(title) try { await api.postWithToken('csrf', {     action: 'purge',      titles: strippedTitle    }) $statusLine.html('Purged ' + $link.prop('outerHTML')) } catch (error) { $statusLine.addClass('spihelper-errortext').html('Failed to purge ' + $link.prop('outerHTML') + ': ' + error) } }

/** * Blocks a user. * * @param {string} user Username to block * @param {string} duration Duration of the block * @param {string} reason Reason to log for the block * @param {boolean} reblock Whether to reblock - if false, nothing will happen if the target user is already blocked * @param {boolean} anononly For IPs, whether this is an anonymous-only block (alternative is *                          that logged-in users with the IP are also blocked) * @param {boolean} accountcreation Whether to permit the user to create new accounts * @param {boolean} autoblock Whether to apply an autoblock to the user's IP * @param {boolean} talkpage Whether to revoke talkpage access * @param {boolean} email Whether to block email * @param {boolean} watchBlockedUser Watchlist setting for whether to watch the newly-blocked user * @param {string} watchExpiry Duration to watch the blocked user, if unset *                            defaults to 'indefinite'

* @return {Promise } True if the block suceeded, false if not */ async function spiHelperBlockUser (user, duration, reason, reblock, anononly, accountcreation, autoblock, talkpage, email, watchBlockedUser, watchExpiry) { 'use strict' const activeOpKey = 'block_' + user spiHelperActiveOperations.set(activeOpKey, 'running')

if (!watchExpiry) { watchExpiry = 'indefinite' } const userPage = 'User:' + user const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) const $link = $('<a>').attr('href', mw.util.getUrl(userPage)).attr('title', userPage).text(user) $statusLine.html('Blocking ' + $link.prop('outerHTML'))

// This is not something which should ever be cross-wiki const api = new mw.Api try { await api.postWithToken('csrf', {     action: 'block',      expiry: duration,      reason: reason,      reblock: reblock,      anononly: anononly,      nocreate: accountcreation,      autoblock: autoblock,      allowusertalk: !talkpage,      noemail: email,      watchuser: watchBlockedUser,      watchlistexpiry: watchExpiry,      user: user    }) $statusLine.html('Blocked ' + $link.prop('outerHTML')) spiHelperActiveOperations.set(activeOpKey, 'success') return true } catch (error) { $statusLine.addClass('spihelper-errortext').html('Failed to block ' + $link.prop('outerHTML') + ': ' + error) spiHelperActiveOperations.set(activeOpKey, 'failed') return false } }

/** * Get whether a user is currently blocked * * @param {string} user Username * @return {Promise } Block reason, empty string if not blocked */ async function spiHelperGetUserBlockReason (user) { 'use strict' // This is not something which should ever be cross-wiki const api = new mw.Api try { const response = await api.get({     action: 'query',      list: 'blocks',      bklimit: '1',      bkusers: user,      bkprop: 'user|reason'    }) if (response.query.blocks.length === 0) { // If the length is 0, then the user isn't blocked return '' }   return response.query.blocks[0].reason } catch (error) { return '' } }

/** * Get whether a user is currently globally locked * * @param {string} user Username * @return {Promise } Whether the user is globally locked */ async function spiHelperIsUserGloballyLocked (user) { 'use strict' // This is not something which should ever be cross-wiki const api = new mw.Api try { const response = await api.get({     action: 'query',      list: 'globalallusers',      agulimit: '1',      agufrom: user,      aguto: user,      aguprop: 'lockinfo'    }) if (response.query.globalallusers.length === 0) { // If the length is 0, then we couldn't find the global user return false }   // If the 'locked' field is present, then the user is locked return 'locked' in response.query.globalallusers[0] } catch (error) { return false } }

/** * Get a page's latest revision ID - useful for preventing edit conflicts * * @param {string} title Title of the page * @return {Promise } Latest revision of a page, 0 if it doesn't exist */ async function spiHelperGetPageRev (title) { 'use strict'

const finalTitle = spiHelperStripXWikiPrefix(title) const request = { action: 'query', prop: 'revisions', rvslots: 'main', indexpageids: true, titles: finalTitle }

try { const response = await spiHelperGetAPI(title).get(request) const pageid = response.query.pageids[0] if (pageid === '-1') { return 0 }   return response.query.pages[pageid].revisions[0].revid } catch (error) { return 0 } }

/** * Delete a page. Admin-only function. * * @param {string} title Title of the page to delete * @param {string} reason Reason to log for the page deletion */ async function spiHelperDeletePage (title, reason) { 'use strict'

const activeOpKey = 'delete_' + title spiHelperActiveOperations.set(activeOpKey, 'running')

const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title) $statusLine.html('Deleting ' + $link.prop('outerHTML'))

const api = spiHelperGetAPI(title) try { await api.postWithToken('csrf', {     action: 'delete',      title: title,      reason: reason    }) $statusLine.html('Deleted ' + $link.prop('outerHTML')) spiHelperActiveOperations.set(activeOpKey, 'success') } catch (error) { $statusLine.addClass('spihelper-errortext').html('Failed to delete ' + $link.prop('outerHTML') + ': ' + error) spiHelperActiveOperations.set(activeOpKey, 'failed') } }

/** * Undelete a page (or, if the page exists, undelete deleted revisions). Admin-only function * * @param {string} title Title of the pgae to undelete * @param {string} reason Reason to log for the page undeletion */ async function spiHelperUndeletePage (title, reason) { 'use strict' const activeOpKey = 'undelete_' + title spiHelperActiveOperations.set(activeOpKey, 'running')

const $statusLine = $('<li>').appendTo($('#spiHelper_status', document)) const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title) $statusLine.html('Undeleting ' + $link.prop('outerHTML'))

const api = spiHelperGetAPI(title) try { await api.postWithToken('csrf', {     action: 'undelete',      title: title,      reason: reason    }) $statusLine.html('Undeleted ' + $link.prop('outerHTML')) spiHelperActiveOperations.set(activeOpKey, 'success') } catch (error) { $statusLine.addClass('spihelper-errortext').html('Failed to undelete ' + $link.prop('outerHTML') + ': ' + error) spiHelperActiveOperations.set(activeOpKey, 'failed') } }

/** * Render a snippet of wikitext * * @param {string} title Page title * @param {string} text Text to render * @return {Promise } Rendered version of the text */ async function spiHelperRenderText (title, text) { 'use strict'

const request = { action: 'parse', prop: 'text', pst: 'true', text: text, title: title }

try { const response = await spiHelperGetAPI(title).get(request) return response.parse.text['*'] } catch (error) { console.error('Error rendering text: ' + error) return '' } }

/** * Get a list of investigations on the sockpuppet investigation page * * @return {Promise<Object[]>} An array of section objects, each section is a separate investigation */ async function spiHelperGetInvestigationSectionIDs { // Uses the parse API to get page sections, then find the investigation // sections (should all be level-3 headers) 'use strict'

// Since this only affects the local page, no need to call spiHelper_getAPI const api = new mw.Api const response = await api.get({   action: 'parse',    prop: 'sections',    page: spiHelperPageName  }) const dateSections = [] for (let i = 0; i < response.parse.sections.length; i++) { // TODO: also check for presence of spi case status if (response.parse.sections[i].level === '3') { dateSections.push(response.parse.sections[i]) } }  return dateSections }

/** * Pretty obvious - gets the name of the archive. This keeps us from having to regen it * if we rename the case * * @return {string} Name of the archive page */ function spiHelperGetArchiveName { return spiHelperPageName + '/Archive' }

// UI helper functions /** * Generate a line of the block table for a particular user * * @param {string} name Username for this block line * @param {boolean} defaultblock Whether to check the block box by default on this row * @param {number} id Index of this line in the block table */ function spiHelperGenerateBlockTableLine (name, defaultblock, id) { 'use strict'

const $table = $('#spiHelper_blockTable', document)

const $row = $(' ') // Username $(' ').append($(' ').attr('type', 'text').attr('id', 'spiHelper_block_username' + id)   .val(name).addClass('.spihelper-widthlimit')).appendTo($row) // Block checkbox (only for admins) $(' ').addClass('spiHelper_adminClass').append($(' ').attr('type', 'checkbox')   .attr('id', 'spiHelper_block_doblock' + id).prop('checked', defaultblock)).appendTo($row) // Block duration (only for admins) const defaultBlockDuration = mw.util.isIPAddress(name, true) ? '1 week' : 'indefinite' $(' ').addClass('spiHelper_adminClass').append($(' ').attr('type', 'text')   .attr('id', 'spiHelper_block_duration' + id).val(defaultBlockDuration)    .addClass('.spihelper-widthlimit')).appendTo($row) // Account creation blocked (only for admins) $(' ').addClass('spiHelper_adminClass').append($(' ').attr('type', 'checkbox')   .attr('id', 'spiHelper_block_acb' + id).prop('checked', true)).appendTo($row) // Autoblock (only for admins) $(' ').addClass('spiHelper_adminClass').append($(' ').attr('type', 'checkbox')   .attr('id', 'spiHelper_block_ab' + id).prop('checked', true)).appendTo($row) // Revoke talk page access (only for admins) $(' ').addClass('spiHelper_adminClass').append($(' ').attr('type', 'checkbox')   .attr('id', 'spiHelper_block_tp' + id)).appendTo($row) // Block email access (only for admins) $(' ').addClass('spiHelper_adminClass').append($(' ').attr('type', 'checkbox')   .attr('id', 'spiHelper_block_email' + id)).appendTo($row) // Tag select box $(' ').append($(' ').attr('id', 'spiHelper_block_tag' + id)   .val(name)).appendTo($row) // Altmaster tag select $(' ').append($(' ').attr('id', 'spiHelper_block_tag_altmaster' + id)   .val(name)).appendTo($row) // Global lock (disabled for IPs since they can't be locked) $(' ').append($(' ').attr('type', 'checkbox').attr('id', 'spiHelper_block_lock' + id)   .prop('disabled', mw.util.isIPAddress(name, true))).appendTo($row) $table.append($row)

// Generate the select entries spiHelperGenerateSelect('spiHelper_block_tag' + id, spiHelperTagOptions) spiHelperGenerateSelect('spiHelper_block_tag_altmaster' + id, spiHelperAltMasterTagOptions) }

/** * Complicated function to decide what checkboxes to enable or disable * and which to check by default */ async function spiHelperSetCheckboxesBySection { // Displays the top-level SPI menu 'use strict'

const $topView = $('#spiHelper_topViewDiv', document) // Get the value of the selection box if ($('#spiHelper_sectionSelect', $topView).val === 'all') { spiHelperSectionId = null spiHelperSectionName = null } else { spiHelperSectionId = parseInt($('#spiHelper_sectionSelect', $topView).val.toString) const $sectionSelect = $('#spiHelper_sectionSelect', $topView) spiHelperSectionName = spiHelperCaseSections[$sectionSelect.prop('selectedIndex')].line }

const $warningText = $('#spiHelper_warning', $topView) $warningText.hide

const $archiveBox = $('#spiHelper_Archive', $topView) const $blockBox = $('#spiHelper_BlockTag', $topView) const $closeBox = $('#spiHelper_Close', $topView) const $commentBox = $('#spiHelper_Comment', $topView) const $moveBox = $('#spiHelper_Move', $topView) const $caseActionBox = $('#spiHelper_Case_Action', $topView) const $spiMgmtBox = $('#spiHelper_SpiMgmt', $topView)

// Start by unchecking everything $archiveBox.prop('checked', false) $blockBox.prop('checked', false) $closeBox.prop('checked', false) $commentBox.prop('checked', false) $moveBox.prop('checked', false) $caseActionBox.prop('checked', false) $spiMgmtBox.prop('checked', false)

// Enable optionally-disabled boxes $closeBox.prop('disabled', false) $archiveBox.prop('disabled', false)

if (spiHelperSectionId === null) { // Hide inputs that aren't relevant in the case view $('.spiHelper_singleCaseOnly', $topView).hide // Show inputs only visible in all-case mode $('.spiHelper_allCasesOnly', $topView).show // Fix the move label $('#spiHelper_moveLabel', $topView).text('Move/merge full case (Clerk only)') // enable the move box $moveBox.prop('disabled', false) } else { const sectionText = await spiHelperGetPageText(spiHelperPageName, false, spiHelperSectionId) if (!spiHelperSectionRegex.test(sectionText)) { // Nothing to do here. return }

// Unhide single-case options $('.spiHelper_singleCaseOnly', $topView).show // Hide inputs only visible in all-case mode $('.spiHelper_allCasesOnly', $topView).hide

const result = spiHelperCaseStatusRegex.exec(sectionText) let casestatus = '' if (result) { casestatus = result[1] } else { $warningText.text(`Can't find case status in ${spiHelperSectionName}!`) $warningText.show }

// Disable the section move setting if you haven't opted into it   if (!spiHelperSettings.iUnderstandSectionMoves) { $moveBox.prop('disabled', true) }

const isClosed = spiHelperCaseClosedRegex.test(casestatus)

if (isClosed) { $closeBox.prop('disabled', true) $archiveBox.prop('checked', true) } else { $archiveBox.prop('disabled', true) }

// Change the label on the rename button $('#spiHelper_moveLabel', $topView).html('Move case section (<span title="You probably want to move the full case, ' +     'select All Sections instead of a specific date in the drop-down"' + 'class="rt-commentedText spihelper-hovertext">READ ME FIRST )') } }

/** * Updates whether the 'archive' checkbox is enabled */ function spiHelperUpdateArchive { // Archive should only be an option if close is checked or disabled (disabled meaning that // the case is closed) and rename is not checked 'use strict' $('#spiHelper_Archive', document).prop('disabled', !($('#spiHelper_Close', document).prop('checked') || $('#spiHelper_Close', document).prop('disabled')) || $('#spiHelper_Move', document).prop('checked')) if ($('#spiHelper_Archive', document).prop('disabled')) { $('#spiHelper_Archive', document).prop('checked', false) } }

/** * Updates whether the 'move' checkbox is enabled */ function spiHelperUpdateMove { // Rename is mutually exclusive with archive 'use strict' $('#spiHelper_Move', document).prop('disabled', $('#spiHelper_Archive', document).prop('checked')) if ($('#spiHelper_Move', document).prop('disabled')) { $('#spiHelper_Move', document).prop('checked', false) } }

/** * Generate a select input, optionally with an onChange call * * @param {string} id Name of the input * @param {SelectOption[]} options Array of options objects */ function spiHelperGenerateSelect (id, options) { // Add the dates to the selector const $selector = $('#' + id, document) for (let i = 0; i < options.length; i++) { const o = options[i] $(' ')     .val(o.value) .prop('selected', o.selected) .text(o.label) .prop('disabled', o.disabled) .appendTo($selector) } }

/** * Given an HTML element, sets that element's value on all block options * For example, checking the 'block all' button will check all per-user 'block' elements * * @param {JQuery<HTMLElement>} source The HTML input element that we're matching all selections to */ function spiHelperSetAllBlockOpts (source) { 'use strict' for (let i = 1; i <= spiHelperUserCount; i++) { const $target = $('#' + source.attr('id') + i)   if (source.attr('type') === 'checkbox') { // Don't try to set disabled checkboxes if (!$target.prop('disabled')) { $target.prop('checked', source.prop('checked')) }   } else { $target.val(source.val) } } }

/** * Inserts text at the cursor's position * * @param {JQuery<HTMLElement>} source Select box that was changed * @param {number?} pos Position to insert text; if null, inserts at the cursor */ function spiHelperInsertTextFromSelect (source, pos = null) { const $textBox = $('#spiHelper_CommentText', document) // https://stackoverflow.com/questions/11076975/how-to-insert-text-into-the-textarea-at-the-current-cursor-position const selectionStart = parseInt($textBox.attr('selectionStart')) const selectionEnd = parseInt($textBox.attr('selectionEnd')) const startText = $textBox.val.toString const newText = source.val.toString if (pos === null && (selectionStart || selectionStart === 0)) { $textBox.val(startText.substring(0, selectionStart) +     newText +      startText.substring(selectionEnd, startText.length)) $textBox.attr('selectionStart', selectionStart + newText.length) $textBox.attr('selectionEnd', selectionEnd + newText.length) } else if (pos !== null) { $textBox.val(startText.substring(0, pos) +     source.val +      startText.substring(pos, startText.length)) $textBox.attr('selectionStart', selectionStart + newText.length) $textBox.attr('selectionEnd', selectionEnd + newText.length) } else { $textBox.val(startText + newText) }

// Force the selected element to reset its selection to 0 source.prop('selectedIndex', 0) }

/** * Inserts a template at the start of the text box * * @param {JQuery<HTMLElement>} source Select box that was changed */ function spiHelperInsertNote (source) { 'use strict' const $textBox = $('#spiHelper_CommentText', document) let newText = $textBox.val.toString // Match the start of the line, optionally including a '*' with or without whitespace around it, // optionally including a template which contains the string "note" newText = newText.replace(/^(\s*\*\s*)?(\s*)?/i, '* ') $textBox.val(newText)

// Force the selected element to reset its selection to 0 source.prop('selectedIndex', 0) }

/** * Changes the case status in the comment box * * @param {JQuery<HTMLElement>} source Select box that was changed */ function spiHelperCaseActionUpdated (source) { const $textBox = $('#spiHelper_CommentText', document) const oldText = $textBox.val.toString let newTemplate = '' switch (source.val) { case 'CUrequest': newTemplate = '' break case 'admin': newTemplate = '' break case 'clerk': newTemplate = '' break case 'selfendorse': newTemplate = '' break case 'inprogress': newTemplate = '' break case 'decline': newTemplate = '' break case 'cudecline': newTemplate = '' break case 'endorse': newTemplate = '' break case 'cuendorse': newTemplate = '' break case 'moreinfo': // Intentional fallthrough case 'cumoreinfo': newTemplate = '' break case 'relist': newTemplate = '' break case 'hold': case 'cuhold': newTemplate = '' break } if (spiHelperClerkStatusRegex.test(oldText)) { $textBox.val(oldText.replace(spiHelperClerkStatusRegex, newTemplate)) if (!newTemplate) { // If the new template is empty, get rid of the stray ' - ' $textBox.val(oldText.replace(/^ - /, '')) } } else if (newTemplate) { // Don't try to insert if the "new template" is empty // Also remove the leading * $textBox.val('*' + newTemplate + ' - ' + oldText.replace(/^\s*\*\s*/, '')) } }

/** * Fires on page load, adds the SPI portlet and (if the page is categorized as "awaiting * archive," meaning that at least one closed template is on the page) the SPI-Archive portlet */ async function spiHelperAddLink { 'use strict' await spiHelperLoadSettings await mw.loader.load('mediawiki.util') const initLink = mw.util.addPortletLink('p-cactions', '#', 'SPI', 'ca-spiHelper') initLink.addEventListener('click', (e) => {   e.preventDefault    return spiHelperInit  }) if (mw.config.get('wgCategories').includes('SPI cases awaiting archive') && spiHelperIsClerk) { const oneClickArchiveLink = mw.util.addPortletLink('p-cactions', '#', 'SPI-Archive', 'ca-spiHelperArchive') $(oneClickArchiveLink).one('click', (e) => {     e.preventDefault      return spiHelperOneClickArchive    }) } window.addEventListener('beforeunload', (e) => {    const $actionView = $('#spiHelper_actionViewDiv', document)    if ($actionView.length > 0) {      e.preventDefault      return true    }

// Make sure no operations are still in flight let isDirty = false spiHelperActiveOperations.forEach((value, _0, _1) => {     if (value === 'running') {        isDirty = true      }    }) if (isDirty) { e.preventDefault return true } }) }

/** * Checks for the existence of Special:MyPage/spihelper-options.js, and if it exists, * loads the settings from that page. */ async function spiHelperLoadSettings { // Dynamically load a user's settings // Borrowed from code I wrote for User:Headbomb/unreliable.js try { await mw.loader.getScript('/w/index.php?title=Special:MyPage/spihelper-options.js&action=raw&ctype=text/javascript') if (typeof spiHelperCustomOpts !== 'undefined') { Object.entries(spiHelperCustomOpts).forEach(([k, v]) => {       spiHelperSettings[k] = v      }) } } catch (error) { mw.log.error('Error retrieving your spihelper-options.js') // More detailed error in the console console.error('Error getting local spihelper-options.js: ' + error) } }

// User role helper functions /** * Whether the current user has admin permissions, used to determine * whether to show block options * * @return {boolean} Whether the current user is an admin */ function spiHelperIsAdmin { if (spiHelperSettings.debugForceAdminState !== null) { return spiHelperSettings.debugForceAdminState } return mw.config.get('wgUserGroups').includes('sysop') }

/** * Whether the current user has checkuser permissions, used to determine * whether to show checkuser options * * @return {boolean} Whether the current user is a checkuser */

function spiHelperIsCheckuser { if (spiHelperSettings.debugForceCheckuserState !== null) { return spiHelperSettings.debugForceCheckuserState } return mw.config.get('wgUserGroups').includes('checkuser') }

/** * Whether the current user is a clerk, used to determine whether to show * clerk options * * @return {boolean} Whether the current user is a clerk */ function spiHelperIsClerk { // Assumption: checkusers should see clerk options. Please don't prove this wrong. return spiHelperSettings.clerk || spiHelperIsCheckuser }

/** * Common username normalization function * @param {string} username Username to normalize * * @return {string} Normalized username */ function spiHelperNormalizeUsername (username) { // Replace underscores with spaces username = username.replace('_', ' ') // Get rid of bad hidden characters username = username.replace(spiHelperHiddenCharNormRegex, '') // Remove leading and trailing spaces username = username.trim if (mw.util.isIPAddress(username, true)) { // For IP addresses, capitalize them (really only applies to IPv6) username = username.toUpperCase } else { // For actual usernames, make sure the first letter is capitalized username = username.charAt(0).toUpperCase + username.slice(1) } return username } //

/** * Parse key features from an archivenotice * @param {string} page Page to parse * * @return {Promise<ParsedArchiveNotice>} Parsed archivenotice */ async function spiHelperParseArchiveNotice (page) { const pagetext = await spiHelperGetPageText(page, false) const match = spiHelperArchiveNoticeRegex.exec(pagetext) const username = match[1] let deny = false let xwiki = false if (match[2]) { for (const entry of match[2].split('|')) { if (!entry) { // split in such a way that it's just a pipe continue }     const splitEntry = entry.split('=') if (splitEntry.length !== 2) { console.error('Malformed archivenotice parameter ' + entry) continue }     const key = splitEntry[0] const val = splitEntry[1] if (val.toLowerCase !== 'yes') { // Only care if the value is 'yes' continue }     if (key.toLowerCase === 'deny') { deny = true } else if (key.toLowerCase === 'crosswiki') { xwiki = true }   }  }  /** @type {ParsedArchiveNotice} */ return { username: username, deny: deny, xwiki: xwiki } }

/** * Helper function to make a new archivenotice * @param {string} username Username * @param {ParsedArchiveNotice} archiveNoticeParams Other archivenotice params * * @return {string} New archivenotice */ function spiHelperMakeNewArchiveNotice (username, archiveNoticeParams) { let notice = ''

return notice }

/** * Function to add a blank user line to the block table * * Would fail ESlint no-unused-vars due to only being * referenced in an onclick event * * @return {void} */ // eslint-disable-next-line no-unused-vars function spiHelperAddBlankUserLine { spiHelperUserCount++ spiHelperGenerateBlockTableLine('', true, spiHelperUserCount) }