Jump to content

User:GeneralNotability/spihelper.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by GeneralNotability (talk | contribs) at 14:18, 20 January 2021 (2.3.0, actual feature update! support temporary watchlisting. Also fix warning untagged users, tag globally locked but not blocked users correctly, minor rework of case status workflow). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/* eslint-disable es/no-object-entries */
/* eslint-disable no-restricted-syntax */
// <nowiki>
// @ts-check
// GeneralNotability's rewrite of Tim's SPI helper script
// v2.3.0 "Editing Under the Influence"

// 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
 */

// Globals
// User-configurable settings, these are the defaults but will be updated by
// spiHelper_loadSettings()
const spiHelper_settings = {
	// 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,
	// 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. Wikipedia:Sockpuppet investigations/Test) */
let spiHelper_pageName = 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 spiHelper_startingRevID = mw.config.get('wgCurRevisionId');

// Just the username part of the case
let spiHelper_caseName = spiHelper_pageName.replace(/Wikipedia:Sockpuppet investigations\//g, '');

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

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

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

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

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

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

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

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

/** @type {SelectOption[]} List of possible selections for tagging a user in the block/tag interface
 */
const spiHelper_TAG_OPTIONS = [
	{ 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 spiHelper_ALTMASTER_TAG_OPTIONS = [
	{ 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 spiHelper_CU_TEMPLATES = [
	{ label: 'CU templates', selected: true, value: '', disabled: true },
	{ label: 'Confirmed', selected: false, value: '{{confirmed}}' },
	{ label: 'Confirmed/No Comment', selected: false, value: '{{confirmed-nc}}' },
	{ label: 'Indistinguishable', selected: false, value: '{{tallyho}}' },
	{ label: 'Likely', selected: false, value: '{{likely}}' },
	{ label: 'Possilikely', selected: false, value: '{{possilikely}}' },
	{ label: 'Possible', selected: false, value: '{{possible}}' },
	{ label: 'Unlikely', selected: false, value: '{{unlikely}}' },
	{ label: 'Unrelated', selected: false, value: '{{unrelated}}' },
	{ label: 'Inconclusive', selected: false, value: '{{inconclusive}}' },
	{ label: 'Need behavioral eval', selected: false, value: '{{behav}}' },
	{ label: 'No sleepers', selected: false, value: '{{nosleepers}}' },
	{ label: 'Stale', selected: false, value: '{{stale}}' }
];

/** @type {SelectOption[]} Templates that a clerk or admin might insert */
const spiHelper_ADMIN_TEMPLATES = [
	{ label: 'Admin/clerk templates', selected: true, value: '', disabled: true },
	{ label: 'Duck', selected: false, value: '{{duck}}' },
	{ label: 'Megaphone Duck', selected: false, value: '{{megaphone duck}}' },
	{ label: 'Blocked and tagged', selected: false, value: '{{bnt}}' },
	{ label: 'Blocked, no tags', selected: false, value: '{{bwt}}' },
	{ label: 'Blocked, awaiting tags', selected: false, value: '{{sblock}}' },
	{ label: 'Blocked, tagged, closed', selected: false, value: '{{btc}}' },
	{ label: 'Diffs needed', selected: false, value: '{{DiffsNeeded|moreinfo}}' },
	{ label: 'Locks requested', selected: false, value: '{{GlobalLocksRequested}}' }
];

// Regex to match the case status, group 1 is the actual status
const spiHelper_CASESTATUS_RE = /{{\s*SPI case status\s*\|?\s*(\S*?)\s*}}/i;
// Regex to match closed case statuses (close or closed)
const spiHelper_CASESTATUS_CLOSED_RE = /^closed?$/i;

const spiHelper_CLERKSTATUS_RE = /{{(CURequest|awaitingadmin|clerk ?request|(?:self|requestand|cu)?endorse|inprogress|decline(?:-ip)?|moreinfo|relisted|onhold)}}/i;

const spiHelper_SOCK_SECTION_RE_WITH_NEWLINE = /====\s*Suspected sockpuppets\s*====\n*/i;

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

const spiHelper_CU_BLOCK_RE = /{{(checkuserblock(-account|-wide)?|checkuser block)}}/i;

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

const spihelper_ADVERT = ' (using [[:w:en:User:GeneralNotability/spihelper|spihelper.js]])';

// The current wiki's interwiki prefix
const spiHelper_interwikiPrefix = spiHelper_getInterwikiPrefix();

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

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

const spiHelper_TOP_VIEW = `
<div id="spiHelper_topViewDiv">
	<h3>Handling SPI case</h3>
	<select id="spiHelper_sectionSelect"/>
	<ul>
		<li id="spiHelper_actionLine">
			<input type="checkbox" checked="checked" name="spiHelper_Case_Action" id="spiHelper_Case_Action" />
			<label for="spiHelper_Case_Action">Change case status</label>
		</li>
		<li id="spiHelper_blockLine" class="spiHelper_adminClerkClass">
			<input type="checkbox" name="spiHelper_BlockTag" id="spiHelper_BlockTag" />
			<label for="spiHelper_BlockTag">Block/tag socks</label>
		</li>
		<li id="spiHelper_commentLine">
			<input type="checkbox" name="spiHelper_Comment" id="spiHelper_Comment" />
			<label for="spiHelper_Comment">Note/comment</label>
  		</li>
		<li id="spiHelper_closeLine" class="spiHelper_adminClerkClass">
			<input type="checkbox" name="spiHelper_Close" id="spiHelper_Close")" />
			<label for="spiHelper_Close">Close case</label>
		</li>
		<li id="spiHelper_moveLine" class="spiHelper_clerkClass">
			<input type="checkbox" name="spiHelper_Move" id="spiHelper_Move" />
			<label for="spiHelper_Move" id="spiHelper_moveLabel">Move/merge full case (Clerk only)</label>
		</li>
		<li id="spiHelper_archiveLine" class="spiHelper_clerkClass">
			<input type="checkbox" name="spiHelper_Archive" id="spiHelper_Archive"/>
			<label for="spiHelper_Archive">Archive case (Clerk only)</label>
		</li>
	</ul>
	<input type="button" id="spiHelper_GenerateForm" name="spiHelper_GenerateForm" value="Continue" onclick="spiHelper_generateForm()" />
</div>
`;

/**
 * Initialization functions for spiHelper, displays the top-level menu
 */
async function spiHelper_init() {
	'use strict';
	spiHelper_caseSections = await spiHelper_getInvestigationSectionIDs();

	// First, insert the template text
	displayMessage(spiHelper_TOP_VIEW);

	// 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(spiHelper_isAdmin() ? 'Block/tag socks' : 'Tag socks');

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

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

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

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

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

	// Set the checkboxes to their default states
	spiHelper_setCheckboxesBySection();
}

const spiHelper_ACTION_VIEW = `
<div id="spiHelper_actionViewDiv">
	<small><a id="spiHelper_backLink">Back to top menu</a></small>
	<br />
	<h3>Handling SPI case</h3>
	<div id="spiHelper_actionView">
		<h4>Changing case status</h4>
		<label for="spiHelper_CaseAction">New status:</label>
		<select id="spiHelper_CaseAction"/>
	</div>
	<div id="spiHelper_blockTagView">
		<h4 id="spiHelper_blockTagHeader">Blocking and tagging socks</h4>
		<ul>
			<li class="spiHelper_adminClass">
				<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)</label>
			</li>
			<li class="spiHelper_adminClass">
				<input type="checkbox" name="spiHelper_override" id="spiHelper_override" />
				<label for="spiHelper_override">Override any existing blocks</label>
			</li>
			<li class="spiHelper_cuClass">
				<input type="checkbox" name="spiHelper_cublock" id="spiHelper_cublock" />
				<label for="spiHelper_cublock">Mark blocks as Checkuser blocks.</label>
			</li>
			<li class="spiHelper_cuClass">
				<input type="checkbox" name="spiHelper_cublockonly" id="spiHelper_cublockonly" />
				<label for="spiHelper_cublockonly">
					Suppress the usual block summary and only use {{checkuserblock-account}} and {{checkuserblock}} (no effect if "mark blocks as CU blocks" is not checked).
				</label>
			</li>
			<li class="spiHelper_adminClass">
				<input type="checkbox" checked="checked" name="spiHelper_blocknoticemaster" id="spiHelper_blocknoticemaster" />
				<label for="spiHelper_blocknoticemaster">Add talk page notice when (re)blocking the sockmaster.</label>
			</li>
			<li class="spiHelper_adminClass">
				<input type="checkbox" name="spiHelper_blocknoticesocks" id="spiHelper_blocknoticesocks" />
				<label for="spiHelper_blocknoticesocks">Add talk page notice when blocking socks.</label>
			</li>
			<li class="spiHelper_adminClass">
				<input type="checkbox" name="spiHelper_blanktalk" id="spiHelper_blanktalk" />
				<label for="spiHelper_blanktalk">Blank the talk page when adding talk notices.</label>
			</li>
			<li>
				<input type="checkbox" name="spiHelper_hidelocknames" id="spiHelper_hidelocknames" />
				<label for="spiHelper_hidelocknames">Hide usernames when requesting global locks.</label>
			</li>
		</ul>
		<table id="spiHelper_blockTable" style="border-collapse:collapse;">
			<tr>
				<th>Username</th>
				<th class="spiHelper_adminClass"><span title="Block user" class="rt-commentedText spihelper-hovertext">Blk?</span></th>
				<th class="spiHelper_adminClass"><span title="Block duration" class="rt-commentedText spihelper-hovertext">Duration</span></th>
				<th class="spiHelper_adminClass"><span title="Account creation blocked" class="rt-commentedText spihelper-hovertext">ACB</span></th>
				<th class="spiHelper_adminClass"><span title="Autoblock (for logged-in users)/Anonymous-only (for IPs)" class="rt-commentedText spihelper-hovertext">AB/AO</span></th>
				<th class="spiHelper_adminClass"><span title="Disable talk page access" class="rt-commentedText spihelper-hovertext">NTP</span></th>
				<th class="spiHelper_adminClass"><span title="Disable email" class="rt-commentedText spihelper-hovertext">NEM</span></th>
				<th>Tag</th>
				<th><span title="Tag the user with a suspected alternate master" class="rt-commentedText spihelper-hovertext">Alt Master</span></th>
				<th><span title="Request a global lock at Meta:SRG" class="rt-commentedText spihelper-hovertext">Req Lock?</span></th>
			</tr>
			<tr style="border-bottom:2px solid black">
				<td style="text-align:center;">(All users)</td>
				<td class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_block_doblock"/></td>
				<td class="spiHelper_adminClass"></td>
				<td class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_block_acb" checked="checked"/></td>
				<td class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_block_ab" checked="checked"/></td>
				<td class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_block_tp"/></td>
				<td class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_block_email"/></td>
				<td><select id="spiHelper_block_tag"/></td>
				<td><select id="spiHelper_block_tag_altmaster"/></td>
	
				<td><input type="checkbox" name="spiHelper_block_lock_all" id="spiHelper_block_lock"/></td>
			</tr>
		</table>
	</div>
	<div id="spiHelper_closeView">
		<h4>Marking case as closed</h4>
		<input type="checkbox" checked="checked" id="spiHelper_CloseCase" />
		<label for="spiHelper_CloseCase">Close this SPI case</label>
	</div>
	<div id="spiHelper_moveView">
		<h4 id="spiHelper_moveHeader">Move section</h4>
		<label for="spiHelper_moveTarget">New sockmaster username: </label>
		<input type="text" name="spiHelper_moveTarget" id="spiHelper_moveTarget" />
	</div>
	<div id="spiHelper_archiveView">
		<h4>Archiving case</h4>
		<input type="checkbox" checked="checked" name="spiHelper_ArchiveCase" id="spiHelper_ArchiveCase" />
		<label for="spiHelper_ArchiveCase">Archive this SPI case</label>
	</div>
	<div id="spiHelper_commentView">
		<h4>Comments</h4>
		<span>
			<select id="spiHelper_noteSelect"/>
			<select class="spiHelper_adminClerkClass" id="spiHelper_adminSelect"/>
			<select class="spiHelper_cuClass" id="spiHelper_cuSelect"/>
		</span>
		<div>
			<label for="spiHelper_CommentText">Comment:</label>
			<textarea rows="3" cols="80" id="spiHelper_CommentText">*</textarea>
			<div><a id="spiHelper_previewLink">Preview</a></div>
		</div>
		<div class="spihelper-previewbox" id="spiHelper_previewBox" hidden/>
	</div>
	<input type="button" id="spiHelper_performActions" value="Done" />
</div>
`;
/**
 * Big function to generate the SPI form from the top-level menu selections
 */
async function spiHelper_generateForm() {
	'use strict';
	spiHelper_usercount = 0;
	const $topView = $('#spiHelper_topViewDiv', document);
	spiHelper_ActionsSelected.Case_act = $('#spiHelper_Case_Action', $topView).prop('checked');
	spiHelper_ActionsSelected.Block = $('#spiHelper_BlockTag', $topView).prop('checked');
	spiHelper_ActionsSelected.Note = $('#spiHelper_Comment', $topView).prop('checked');
	spiHelper_ActionsSelected.Close = $('#spiHelper_Close', $topView).prop('checked');
	spiHelper_ActionsSelected.Rename = $('#spiHelper_Move', $topView).prop('checked');
	spiHelper_ActionsSelected.Archive = $('#spiHelper_Archive', $topView).prop('checked');
	const pagetext = await spiHelper_getPageText(spiHelper_pageName, false, spiHelper_sectionId);
	if (!(spiHelper_ActionsSelected.Case_act ||
		spiHelper_ActionsSelected.Note || spiHelper_ActionsSelected.Close ||
		spiHelper_ActionsSelected.Archive || spiHelper_ActionsSelected.Block ||
		spiHelper_ActionsSelected.Rename)) {
		displayMessage('');
		return;
	}

	displayMessage(spiHelper_ACTION_VIEW);

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

	// Wire up the action view
	$('#spiHelper_backLink', $actionView).on('click', () => {
		spiHelper_init();
	});
	if (spiHelper_ActionsSelected.Case_act) {
		const result = spiHelper_CASESTATUS_RE.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 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 (spiHelper_CASESTATUS_CLOSED_RE.test(casestatus)) {
			selectOpts.push({ label: 'Reopen', value: 'open', selected: false });
		}
		if (spiHelper_isCheckuser()) {
			selectOpts.push({ label: 'Mark as in progress', value: 'inprogress', selected: false });
		}
		if (spiHelper_isClerk() || spiHelper_isAdmin()) {
			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 (spiHelper_isClerk()) {
				selectOpts.push({ label: 'Request CU and self-endorse', value: 'selfendorse', selected: false });
			}
		}
		// CU already requested
		if (cuRequested && spiHelper_isClerk()) {
			// 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 (spiHelper_isCheckuser()) {
				selectOpts.push({ label: 'Endorse CU as a CheckUser', value: 'cuendorse', selected: false });
			}
			if (spiHelper_isCheckuser()) {
				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 });
		}
		// 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 (spiHelper_isAdmin() || spiHelper_isClerk()) {
			selectOpts.push({ label: 'Mark as checked', value: 'checked', selected: false });
		}
		if (spiHelper_isClerk() && cuCompleted) {
			selectOpts.push({ label: 'Relist for another check', value: 'relist', selected: false });
		}
		if (spiHelper_isCheckuser()) {
			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 (!spiHelper_isAdmin() && spiHelper_isClerk()) {
			selectOpts.push({ label: 'Request admin action', value: 'admin', selected: false });
		}
		// Generate the case action options
		spiHelper_generateSelect('spiHelper_CaseAction', selectOpts);
		// Add the onclick handler to the drop-down
		$('#spiHelper_CaseAction', $actionView).on('change', function (e) {
			spiHelper_caseActionUpdated($(e.target));
		});
	} else {
		$('#spiHelper_actionView', $actionView).hide();
	}

	if (spiHelper_ActionsSelected.Block) {
		if (spiHelper_isAdmin()) {
			$('#spiHelper_blockTagHeader', $actionView).text('Blocking and tagging socks');
		} else {
			$('#spiHelper_blockTagHeader', $actionView).text('Tagging socks');
		}
		const checkuser_re = /{{\s*check(?:user|ip)\s*\|\s*(?:1=)?\s*([^\|}]*?)\s*(?:\|master name\s*=\s*.*)?}}/gi;
		const results = pagetext.match(checkuser_re);
		const likelyusers = [];
		const likelyips = [];
		const possibleusers = [];
		const possibleips = [];
		likelyusers.push(spiHelper_caseName);
		if (results) {
			for (let i = 0; i < results.length; i++) {
				const username = spiHelper_normalizeUsername(results[i].replace(checkuser_re, '$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);
				}
			}
		}
		const user_re = /{{\s*(?:user|vandal|IP)[^\|}{]*?\s*\|\s*(?:1=)?\s*([^\|}]*?)\s*}}/gi;
		const userresults = pagetext.match(user_re);
		if (userresults) {
			for (let i = 0; i < userresults.length; i++) {
				const username = spiHelper_normalizeUsername(userresults[i].replace(user_re, '$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) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_acb', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_ab', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_tp', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_email', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_lock', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_lock', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		spiHelper_generateSelect('spiHelper_block_tag', spiHelper_TAG_OPTIONS);
		$('#spiHelper_block_tag', $actionView).on('change', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		spiHelper_generateSelect('spiHelper_block_tag_altmaster', spiHelper_ALTMASTER_TAG_OPTIONS);
		$('#spiHelper_block_tag_altmaster', $actionView).on('change', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});
		$('#spiHelper_block_lock', $actionView).on('click', function (e) {
			spiHelper_setAllBlockOpts($(e.target));
		});

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

		}
	} else {
		$('#spiHelper_moveView', $actionView).hide();
	}

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

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

		// Wire up the select boxes
		spiHelper_generateSelect('spiHelper_noteSelect', spiHelper_noteTemplates);
		$('#spiHelper_noteSelect', $actionView).on('change', function (e) {
			spiHelper_insertNote($(e.target));
		});
		spiHelper_generateSelect('spiHelper_adminSelect', spiHelper_ADMIN_TEMPLATES);
		$('#spiHelper_adminSelect', $actionView).on('change', function (e) {
			spiHelper_insertTextFromSelect($(e.target));
		});
		spiHelper_generateSelect('spiHelper_cuSelect', spiHelper_CU_TEMPLATES);
		$('#spiHelper_cuSelect', $actionView).on('change', function (e) {
			spiHelper_insertTextFromSelect($(e.target));
		});
		$('#spiHelper_previewLink', $actionView).on('click', function () {
			spiHelper_previewText();
		});
	} else {
		$('#spiHelper_commentView', $actionView).hide();
	}
	// Wire up the submit button
	$('#spiHelper_performActions', $actionView).on('click', () => {
		spiHelper_performActions();
	});

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

/**
 * Archives everything on the page that's eligible for archiving
 */
async function spiHelper_oneClickArchive() {
	'use strict';
	const pagetext = await spiHelper_getPageText(spiHelper_pageName, false);
	spiHelper_caseSections = await spiHelper_getInvestigationSectionIDs();
	if (!spiHelper_SECTION_RE.test(pagetext)) {
		alert('Looks like the page has been archived already.');
		return;
	}
	displayMessage('<ul id="spiHelper_status"/>');
	await spiHelper_archiveCase();
	await spiHelper_purgePage(spiHelper_pageName);
	const logMessage = '* [[' + spiHelper_pageName + ']]: used one-click archiver ~~~~~';
	if (spiHelper_settings.log) {
		spiHelper_log(logMessage);
	}
	$('#spiHelper_status', document).append($('<li>').text('Done!'));
}

/**
 * Another "meaty" function - goes through the action selections and executes them
 */
async function spiHelper_performActions() {
	'use strict';

	// 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 (spiHelper_ActionsSelected.Case_act) {
		newCaseStatus = $('#spiHelper_CaseAction', $actionView).val().toString();
	}
	if (spiHelper_sectionId) {
		comment = $('#spiHelper_CommentText', $actionView).val().toString();
	}
	if (spiHelper_ActionsSelected.Block) {
		if (spiHelper_isCheckuser()) {
			cuBlock = $('#spiHelper_cublock', $actionView).prop('checked');
			cuBlockOnly = $('#spiHelper_cublockonly', $actionView).prop('checked');
		}
		if (spiHelper_isAdmin() && !$('#spiHelper_noblock', $actionView).prop('checked')) {
			const masterNotice = $('#spiHelper_blocknoticemaster', $actionView).prop('checked');
			const sockNotice = $('#spiHelper_blocknoticesocks', $actionView).prop('checked');
			for (let i = 1; i <= spiHelper_usercount; i++) {
				if ($('#spiHelper_block_doblock' + i, $actionView).prop('checked')) {
					let noticetype = '';

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

					/** @type {BlockEntry} */
					const item = {
						username: spiHelper_normalizeUsername($('#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
					};

					spiHelper_blocks.push(item);
				}
				if ($('#spiHelper_block_lock' + i, $actionView).prop('checked')) {
					spiHelper_globalLocks.push($('#spiHelper_block_username' + i, $actionView).val().toString());
				}
				if ($('#spiHelper_block_tag' + i).val() !== '') {
					const item = {
						username: spiHelper_normalizeUsername($('#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')
					};
					spiHelper_tags.push(item);
				}
			}
		} else {
			for (let i = 1; i <= spiHelper_usercount; i++) {
				if ($('#spiHelper_block_tag' + i, $actionView).val() !== '') {
					const item = {
						username: spiHelper_normalizeUsername($('#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
					};
					spiHelper_tags.push(item);
				}
				if ($('#spiHelper_block_lock' + i, $actionView).prop('checked')) {
					spiHelper_globalLocks.push(spiHelper_normalizeUsername($('#spiHelper_block_username' + i, $actionView).val().toString()));
				}
			}
		}
	}
	if (spiHelper_ActionsSelected.Close) {
		spiHelper_ActionsSelected.Close = $('#spiHelper_CloseCase', $actionView).prop('checked');
	}
	if (spiHelper_ActionsSelected.Rename) {
		renameTarget = spiHelper_normalizeUsername($('#spiHelper_moveTarget', $actionView).val().toString());
	}
	if (spiHelper_ActionsSelected.Archive) {
		spiHelper_ActionsSelected.Archive = $('#spiHelper_ArchiveCase', $actionView).prop('checked');
	}

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

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

	let sectionText = await spiHelper_getPageText(spiHelper_pageName, true, spiHelper_sectionId);
	let editsummary = '';
	let logMessage = '* [[' + spiHelper_pageName + ']]';
	if (spiHelper_sectionId) {
		logMessage += ' (section ' + spiHelper_sectionName + ')';
	} else {
		logMessage += ' (full case)';
	}
	logMessage += ' ~~~~~';

	if (spiHelper_sectionId !== null) {
		let caseStatusResult = spiHelper_CASESTATUS_RE.exec(sectionText);
		if (caseStatusResult === null) {
			sectionText = sectionText.replace('===', '{{SPI case status|}}\n===');
			caseStatusResult = spiHelper_CASESTATUS_RE.exec(sectionText);
		}
		const oldCaseStatus = caseStatusResult[1] || 'open';
		if (newCaseStatus === 'noaction') {
			newCaseStatus = oldCaseStatus;
		}

		if (spiHelper_ActionsSelected.Case_act && newCaseStatus !== 'noaction') {
			switch (newCaseStatus) {
				case 'open':
					editsummary = 'Reopening';
					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 (spiHelper_ActionsSelected.Block) {
		let sockmaster = '';
		let altmaster = '';
		let sockcount = 0;
		let needsAltmaster = false;
		spiHelper_tags.forEach(async (tagEntry) => {
			// 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;
			}
			sockcount++;
		});
		if (sockcount > 0 && sockmaster === '') {
			sockmaster = prompt('Please enter the name of the sockmaster: ', spiHelper_caseName);
		}
		if (sockcount > 0 && needsAltmaster) {
			altmaster = prompt('Please enter the name of the alternate sockmaster: ', spiHelper_caseName);
		}

		let blockedList = '';
		if (spiHelper_isAdmin()) {
			const masterNotice = $('#spiHelper_blocknoticemaster', $actionView).prop('checked');
			spiHelper_blocks.forEach(async (blockEntry) => {
				const blockReason = await spiHelper_getUserBlockReason(blockEntry.username);
				if (!spiHelper_isCheckuser() && overrideExisting &&
					spiHelper_CU_BLOCK_RE.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 [[WP:SOCK|multiple accounts]]: Please see: [[' + spiHelper_interwikiPrefix + spiHelper_pageName + ']]';
				if (spiHelper_isCheckuser() && cuBlock) {
					const cublock_template = isIP ? ('{{checkuserblock}}') : ('{{checkuserblock-account}}');
					if (cuBlockOnly) {
						blockSummary = cublock_template;
					} else {
						blockSummary = cublock_template + ': ' + blockSummary;
					}
				} else if (isIPRange) {
					blockSummary = '{{rangeblock| ' + blockSummary +
						(blockEntry.acb ? '' : '|create=yes') + '}}';
				}
				const blockSuccess = await spiHelper_blockUser(
					blockEntry.username,
					blockEntry.duration,
					blockSummary,
					overrideExisting,
					(isIP ? blockEntry.ab : false),
					blockEntry.acb,
					(isIP ? false : blockEntry.ab),
					blockEntry.ntp,
					blockEntry.nem,
					spiHelper_settings.watchBlockedUser,
					spiHelper_settings.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 = $('<li>').appendTo($('#spiHelper_status', document));
						$statusLine.addClass('spiHelper-errortext').html('<b>Block failed on ' + blockEntry.username + ', not adding talk page notice</b>');
					}
					return;
				}
				if (blockedList) {
					blockedList += ', ';
				}
				blockedList += '{{noping|' + blockEntry.username + '}}';
				
				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 (sockmaster && blockEntry.tpn) {
					let newText = '';
					const isSock = blockEntry.tpn.includes('sock');
					if (isSock) {
						newText = '== Blocked as a sockpuppet ==\n';
					} else {
						newText = '== Blocked for sockpuppetry ==\n';
					}
					newText += '{{subst:uw-sockblock|spi=' + spiHelper_caseName;
					if (blockEntry.duration === 'indefinite') {
						newText += '|indef=yes';
					} else {
						newText += '|duration=' + blockEntry.duration;
					}
					if (blockEntry.ntp) {
						newText += '|notalk=yes';
					}
					newText += '|sig=yes';
					if (isSock) {
						newText += '|master=' + sockmaster;
					}
					newText += '}}';

					if (!blankTalk) {
						const oldtext = await spiHelper_getPageText('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
					spiHelper_editPage('User talk:' + blockEntry.username,
						newText, 'Adding sockpuppetry block notice per [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]', 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;
			spiHelper_tags.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 spiHelper_isUserGloballyLocked(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 spiHelper_getUserBlockReason(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 += `{{sockpuppeteer
| 1 = ${tag}
| checked = ${checked}
| locked = ${isLocked}
}}`;
				}
				// 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 += `{{sockpuppet
| 1 = ${sockmasterName}
| 2 = ${tag}
| locked = ${isLocked}
| notblocked = ${isNotBlocked}
| altmaster = ${altmasterName}
| altmaster-status = ${altmasterTag}
}}`;
				}
				spiHelper_editPage('User:' + tagEntry.username, tagText, 'Adding sockpuppetry tag per [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]',
					false, spiHelper_settings.watchTaggedUser, spiHelper_settings.watchTaggedUserExpiry);
				if (tagged) {
					tagged += ', ';
				}
				tagged += '{{noping|' + tagEntry.username + '}}';
			});
			if (tagged) {
				logMessage += '\n** tagged ' + tagged;
			}

			if (checkAltConfirmedCat) {
				const catname = 'Category:Wikipedia sockpuppets of ' + altmaster;
				const cattext = await spiHelper_getPageText(catname, false);
				// Empty text means the page doesn't exist - create it
				if (!cattext) {
					await spiHelper_editPage(catname, '{{sockpuppet category}}',
						'Creating sockpuppet category per [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]',
						true, spiHelper_settings.watchNewCats, spiHelper_settings.watchNewCatsExpiry);
					needsPurge = true;
				}
			}
			if (checkAltSuspectedCat) {
				const catname = 'Category:Suspected Wikipedia sockpuppets of ' + altmaster;
				const cattext = await spiHelper_getPageText(catname, false);
				if (!cattext) {
					await spiHelper_editPage(catname, '{{sockpuppet category}}',
						'Creating sockpuppet category per [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]',
						true, spiHelper_settings.watchNewCats, spiHelper_settings.watchNewCatsExpiry);
					needsPurge = true;
				}
			}
			if (checkConfirmedCat) {
				const catname = 'Category:Wikipedia sockpuppets of ' + sockmaster;
				const cattext = await spiHelper_getPageText(catname, false);
				if (!cattext) {
					await spiHelper_editPage(catname, '{{sockpuppet category}}',
						'Creating sockpuppet category per [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]',
						true, spiHelper_settings.watchNewCats, spiHelper_settings.watchNewCatsExpiry);
					needsPurge = true;
				}
			}
			if (checkSuspectedCat) {
				const catname = 'Category:Suspected Wikipedia sockpuppets of ' + sockmaster;
				const cattext = await spiHelper_getPageText(catname, false);
				if (!cattext) {
					await spiHelper_editPage(catname, '{{sockpuppet category}}',
						'Creating sockpuppet category per [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]',
						true, spiHelper_settings.watchNewCats, spiHelper_settings.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) {
				spiHelper_tags.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
					spiHelper_purgePage('User:' + tagEntry.username);
				});
			}
		}
		if (spiHelper_globalLocks.length > 0) {
			let locked = '';
			let templateContent = '';
			let matchCount = 0;
			spiHelper_globalLocks.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 += '{{noping|' + globalLockEntry + '}}';
				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 = '{{LockHide' + templateContent + '}}';
				} else {
					lockTemplate = '{{MultiLock' + templateContent + '}}';
				}
				if (!sockmaster) {
					sockmaster = prompt('Please enter the name of the sockmaster: ', spiHelper_caseName);
				}
				const lockComment = prompt('Please enter a comment for the global lock request (optional):', '');
				const heading = hideLockNames ? 'sockpuppet(s)' : '[[Special:CentralAuth/' + sockmaster + '|' + sockmaster + ']] sock(s)';
				let message = '=== Global lock for ' + heading + ' ===';
				message += '\n{{status}}';
				message += '\n' + lockTemplate;
				message += '\n* Sockpuppet(s) found in enwiki sockpuppet investigation, see [[' + spiHelper_interwikiPrefix + spiHelper_pageName + ']]. ' + lockComment + ' ~~~~';

				// Write lock request to [[meta:Steward requests/Global]]
				let srgText = await spiHelper_getPageText('meta:Steward requests/Global', false);
				srgText = srgText.replace(/\n+(== See also == *\n)/, '\n\n' + message + '\n\n$1');
				spiHelper_editPage('meta:Steward requests/Global', 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 (spiHelper_sectionId && comment && comment !== '*') {
		if (!sectionText.includes('\n----')) {
			sectionText += '\n----<!-- All comments go ABOVE this line, please. -->';
		}
		if (!/~~~~/.test(comment)) {
			comment += ' ~~~~';
		}
		// Clerks and admins post in the admin section
		if (spiHelper_isClerk() || spiHelper_isAdmin()) {
			// 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|.)*----)/, comment + '\n----');
		} else { // Everyone else posts in the "other users" section
			sectionText = sectionText.replace(spiHelper_ADMIN_SECTION_RE,
				'\n' + comment + '\n====<big>Clerk, CheckUser, and/or patrolling admin comments</big>====\n');
		}
		if (editsummary) {
			editsummary += ', comment';
		} else {
			editsummary = 'Comment';
		}
		logMessage += '\n** commented';
	}

	if (spiHelper_ActionsSelected.Close) {
		newCaseStatus = 'close';
		if (editsummary) {
			editsummary += ', marking case as closed';
		} else {
			editsummary = 'Marking case as closed';
		}
		logMessage += '\n** closed case';
	}
	if (spiHelper_sectionId !== null) {
		const caseStatusText = spiHelper_CASESTATUS_RE.exec(sectionText)[0];
		sectionText = sectionText.replace(caseStatusText, '{{SPI case status|' + newCaseStatus + '}}');
	}

	// 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 spiHelper_editPage(spiHelper_pageName, sectionText, editsummary, false,
		spiHelper_settings.watchCase, spiHelper_settings.watchCaseExpiry, spiHelper_startingRevID, spiHelper_sectionId);
	// Update to the latest revision ID
	spiHelper_startingRevID = await spiHelper_getPageRev(spiHelper_pageName);
	if (spiHelper_ActionsSelected.Archive) {
		// Archive the case
		if (spiHelper_sectionId === null) {
			// Archive the whole case
			logMessage += '\n** Archived case';
			await spiHelper_archiveCase();
		} else {
			// Just archive the selected section
			logMessage += '\n** Archived section';
			await spiHelper_archiveCaseSection(spiHelper_sectionId);
		}
	} else if (spiHelper_ActionsSelected.Rename && renameTarget) {
		if (spiHelper_sectionId === null) {
			// Option 1: we selected "All cases," this is a whole-case move/merge
			logMessage += '\n** moved/merged case to ' + renameTarget;
			await spiHelper_moveCase(renameTarget);
		} else {
			// Option 2: this is a single-section case move or merge
			logMessage += '\n** moved section to ' + renameTarget;
			await spiHelper_moveCaseSection(renameTarget, spiHelper_sectionId);
		}
	}
	if (spiHelper_settings.log) {
		spiHelper_log(logMessage);
	}

	await spiHelper_purgePage(spiHelper_pageName);
	$('#spiHelper_status', document).append($('<li>').text('Done!'));

}

/**
 * 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 spiHelper_log(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');

	let logPageText = await spiHelper_getPageText('User:' + mw.config.get('wgUserName') + '/spihelper_log', false);
	if (!logPageText.match(dateHeaderRe)) {
		logPageText += '\n== ' + dateString + ' ==';
	}
	logPageText += '\n' + logString;
	await spiHelper_editPage('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 spiHelper_postRenameCleanup(oldCasePage) {
	'use strict';
	const replacementArchiveNotice = '<noinclude>__TOC__</noinclude>\n{{SPIarchive notice|' + spiHelper_caseName + '}}\n{{SPIpriorcases}}';
	const oldCaseName = oldCasePage.replace(/Wikipedia:Sockpuppet investigations\//g, '');

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

	// The new case's archivenotice should be updated with the new name
	let newPageText = await spiHelper_getPageText(spiHelper_pageName, true);
	newPageText = newPageText.replace(/{{\s*SPI\s*archive notice\|.*}}/i, '{{SPIarchive notice|' + spiHelper_caseName + '}}');
	// 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(spiHelper_SOCK_SECTION_RE_WITH_NEWLINE, '====Suspected sockpuppets====' +
		'\n* {{checkuser|' + oldCaseName + '}} ({{clerknote}} 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 <big> 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*{{checkuser\\|(?:1=)?' + spiHelper_caseName + '(?:\\|master name\\s*=.*?)?}}\\s*$(.*====\\s*<big>)';
	const newMasterRe = new RegExp(newMasterReString, 'sm');
	newPageText = newPageText.replace(newMasterRe, '$1\n$2');

	await spiHelper_editPage(spiHelper_pageName, newPageText, 'Updating case following page move', false, spiHelper_settings.watchCase, spiHelper_settings.watchCaseExpiry, spiHelper_startingRevID);
	// Update to the latest revision ID
	spiHelper_startingRevID = await spiHelper_getPageRev(spiHelper_pageName);
}

/**
 * Cleanups following a merge - re-insert the original page text
 *
 * @param {string} originalText Text of the page pre-merge
 */
async function spiHelper_postMergeCleanup(originalText) {
	'use strict';
	let newText = await spiHelper_getPageText(spiHelper_pageName, false);
	// Remove the SPI header templates from the page
	newText = newText.replace(/\n*<noinclude>__TOC__.*\n/ig, '');
	newText = newText.replace(/{{spi\s*archive.*\n/ig, '');
	newText = newText.replace(/{{spiprior.*\n/ig, '');
	newText = originalText + '\n' + newText;

	// Write the updated case
	await spiHelper_editPage(spiHelper_pageName, newText, 'Re-adding previous cases following merge', false, spiHelper_settings.watchCase, spiHelper_settings.watchCaseExpiry, spiHelper_startingRevID);
	// Update to the latest revision ID
	spiHelper_startingRevID = await spiHelper_getPageRev(spiHelper_pageName);
}

/**
 * Archive all closed sections of a case
 */
async function spiHelper_archiveCase() {
	'use strict';
	let i = 0;
	while (i < spiHelper_caseSections.length) {
		const sectionId = spiHelper_caseSections[i].index;
		const sectionText = await spiHelper_getPageText(spiHelper_pageName, false,
			sectionId);
		i++;
		const result = spiHelper_CASESTATUS_RE.exec(sectionText);
		if (result === null) {
			// Bail out - can't find the case status template in this section
			continue;
		}
		if (spiHelper_CASESTATUS_CLOSED_RE.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 spiHelper_getPostExpandSize(spiHelper_pageName, sectionId) +
				await spiHelper_getPostExpandSize(spiHelper_getArchiveName())) /
				spiHelper_getMaxPostExpandSize();
			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 spiHelper_getPageText(spiHelper_getArchiveName() + '/' + archiveId, false) !== '') {
					archiveId++;
				}
				const newArchiveName = spiHelper_getArchiveName() + '/' + archiveId;
				await spiHelper_movePage(spiHelper_getArchiveName(), newArchiveName, 'Moving archive to avoid exceeding post expand size limit', false);
			}
			// Need an await here - if we have multiple sections archiving we don't want
			// to stomp on each other
			await spiHelper_archiveCaseSection(sectionId);
			// need to re-fetch caseSections since the section numbering probably just changed,
			// also reset our index
			i = 0;
			spiHelper_caseSections = await spiHelper_getInvestigationSectionIDs();
		}
	}
}

/**
 * Archive a specific section of a case
 *
 * @param {!number} sectionId The section number to archive
 */
async function spiHelper_archiveCaseSection(sectionId) {
	'use strict';
	let sectionText = await spiHelper_getPageText(spiHelper_pageName, true, sectionId);
	sectionText = sectionText.replace(spiHelper_CASESTATUS_RE, '');
	const newarchivetext = sectionText.substring(sectionText.search(spiHelper_SECTION_RE));

	// Blank the section we archived
	await spiHelper_editPage(spiHelper_pageName, '', 'Archiving case section to [[' + spiHelper_getInterwikiPrefix() + spiHelper_getArchiveName() + ']]',
		false, spiHelper_settings.watchCase, spiHelper_settings.watchCaseExpiry, spiHelper_startingRevID, sectionId);
	// Update to the latest revision ID
	spiHelper_startingRevID = await spiHelper_getPageRev(spiHelper_pageName);

	let archivetext = await spiHelper_getPageText(spiHelper_getArchiveName(), true);
	if (!archivetext) {
		archivetext = '__' + 'TOC__\n{{SPIarchive notice|1=' + spiHelper_caseName + '}}\n{{SPIpriorcases}}';
	} else {
		archivetext = archivetext.replace(/<br\s*\/>\s*{{SPIpriorcases}}/gi, '\n{{SPIpriorcases}}'); // fmt fix whenever needed.
	}
	archivetext += '\n' + newarchivetext;
	await spiHelper_editPage(spiHelper_getArchiveName(), archivetext, 'Archiving case section from [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]',
		false, spiHelper_settings.watchArchive, spiHelper_settings.watchArchiveExpiry);
}

/**
 * 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 spiHelper_moveCase(target) {
	// Move or merge an entire case
	// Normalize: change underscores to spaces
	target = target;
	const newPageName = spiHelper_pageName.replace(spiHelper_caseName, target);
	const targetPageText = await spiHelper_getPageText(newPageName, false);
	if (targetPageText) {
		if (spiHelper_isAdmin()) {
			const proceed = confirm('Target page exists, do you want to histmerge the cases?');
			if (!proceed) {
				// Build out the error line
				$('<li>')
					.append($('<div>').addClass('spihelper-errortext')
						.append($('<b>').text('Aborted merge.')))
					.appendTo($('#spiHelper_status', document));
				return;
			}
		} else {
			$('<li>')
				.append($('<div>').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 = spiHelper_pageName;
	spiHelper_caseName = target;
	spiHelper_pageName = newPageName;
	if (targetPageText) {
		// There's already a page there, we're going to merge
		// Ignore warnings on the move, we're going to get one since we're stomping an existing page
		await spiHelper_deletePage(spiHelper_pageName, 'Deleting as part of case merge');
		await spiHelper_movePage(oldPageName, spiHelper_pageName, 'Merging case to [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]', true);
		await spiHelper_undeletePage(spiHelper_pageName, 'Restoring page history after merge');
	} else {
		await spiHelper_movePage(oldPageName, spiHelper_pageName, 'Moving case to [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']]', false);
	}
	spiHelper_startingRevID = await spiHelper_getPageRev(spiHelper_pageName);
	await spiHelper_postRenameCleanup(oldPageName);
	if (targetPageText) {
		// If there was a page there before, also need to do post-merge cleanup
		await spiHelper_postMergeCleanup(targetPageText);
	}
}

/**
 * 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 spiHelper_moveCaseSection(target, sectionId) {
	// Move or merge a particular section of a case
	'use strict';
	const newPageName = spiHelper_pageName.replace(spiHelper_caseName, target);
	let targetPageText = await spiHelper_getPageText(newPageName, false);
	let sectionText = await spiHelper_getPageText(spiHelper_pageName, 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(spiHelper_SOCK_SECTION_RE_WITH_NEWLINE, '====Suspected sockpuppets====' +
	'\n* {{checkuser|' + spiHelper_caseName + '}} ({{clerknote}} originally filed under this user)\n');

	if (targetPageText === '') {
		// Pre-load the split target with the SPI templates if it's empty
		targetPageText = '<noinclude>__TOC__</noinclude>\n{{SPIarchive notice|' + target + '}}\n{{SPIpriorcases}}';
	}
	targetPageText += '\n' + sectionText;

	// Intentionally not async - doesn't matter when this edit finishes
	spiHelper_editPage(newPageName, targetPageText, 'Moving case section from [[' + spiHelper_getInterwikiPrefix() + spiHelper_pageName + ']], see page history for attribution',
		false, spiHelper_settings.watchCase, spiHelper_settings.watchCaseExpiry);
	// Blank the section we moved
	await spiHelper_editPage(spiHelper_pageName, '', 'Moving case section to [[' + spiHelper_getInterwikiPrefix() + newPageName + ']]',
		false, spiHelper_settings.watchCase, spiHelper_settings.watchCaseExpiry, spiHelper_startingRevID, sectionId);
	// Update to the latest revision ID
	spiHelper_startingRevID = await spiHelper_getPageRev(spiHelper_pageName);
}

/**
 * Render a text box's contents and display it in the preview area
 *
 */
async function spiHelper_previewText() {
	const inputText = $('#spiHelper_CommentText', document).val().toString();
	const renderedText = await spiHelper_renderText(spiHelper_pageName, 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 spiHelper_getAPI(title) {
	'use strict';
	if (title.startsWith('m:') || title.startsWith('meta:')) {
		return new mw.ForeignApi('https://meta.wikimedia.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 spiHelper_stripXWikiPrefix(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<number>} Post-expand include size of the given page/page section
 */
async function spiHelper_getPostExpandSize(title, sectionId = null) {
	// Synchronous method to get a page's post-expand include size given its title
	const finalTitle = spiHelper_stripXWikiPrefix(title);

	const request = {
		action: 'parse',
		prop: 'limitreportdata',
		page: finalTitle
	};
	if (sectionId) {
		request.section = sectionId;
	}
	const api = spiHelper_getAPI(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 spiHelper_getMaxPostExpandSize() {
	'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 spiHelper_getInterwikiPrefix() {
	// 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 'wikimedia':
			switch (wikiLang) {
				case 'commons':
					return ':commons:';
				case 'meta':
					return ':meta:';
				case 'species:':
					return ':species:';
				case 'incubator':
					return ':incubator:';
				default:
					return '';
			}
		case 'mediawiki':
			return 'mw';
		case 'wikidata:':
			switch (wikiLang) {
				case 'test':
					return ':testwikidata:';
				case 'www':
					return ':d:';
				default:
					return '';
			}
		case 'wikipedia':
			switch (wikiLang) {
				case 'test':
					return ':testwiki:';
				case 'test2':
					return ':test2wiki:';
				default:
					return ':w:' + wikiLang + ':';
			}
		case 'wiktionary':
			return ':wikt:' + wikiLang + ':';
		case 'wikiquote':
			return ':q:' + wikiLang + ':';
		case 'wikibooks':
			return ':b:' + wikiLang + ':';
		case 'wikinews':
			return ':n:' + wikiLang + ':';
		case 'wikisource':
			return ':s:' + wikiLang + ':';
		case 'wikiversity':
			return ':v:' + wikiLang + ':';
		case 'wikivoyage':
			return ':voy:' + 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<string>} The text of the page, '' if the page does not exist.
 */
async function spiHelper_getPageText(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 = spiHelper_stripXWikiPrefix(title);

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

	if (sectionId) {
		request.rvsection = sectionId;
	}

	try {
		const response = await spiHelper_getAPI(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('<b>Failed to get ' + $link.html() + '</b>: ' + 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
 */
async function spiHelper_editPage(title, newtext, summary, createonly, watch, watchExpiry = null, baseRevId = null, sectionId = null) {
	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 spiHelper_getPageRev(title);
	}
	const api = spiHelper_getAPI(title);
	const finalTitle = spiHelper_stripXWikiPrefix(title);

	const request = {
		action: 'edit',
		watchlist: watch,
		summary: summary + spihelper_ADVERT,
		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'));
	} catch (error) {
		$statusLine.addClass('spiHelper-errortext').html('<b>Edit failed on ' + $link.html() + '</b>: ' + error);
		console.error(error);
	}
}
/**
 * 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 spiHelper_movePage(sourcePage, destPage, summary, ignoreWarnings) {
	// Move a page from sourcePage to destPage. Not that complicated.
	'use strict';

	// 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 + spihelper_ADVERT,
			noredirect: false,
			movesubpages: true,
			ignoreWarnings: ignoreWarnings
		});
		$statusLine.html('Moved ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML'));
	} catch (error) {
		$statusLine.addClass('spihelper-errortext').html('<b>Failed to move ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML') + '</b>: ' + error);
	}
}

/**
 * Purges a page's cache
 *
 *
 * @param {string} title Title of the page to purge
 */
async function spiHelper_purgePage(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 = spiHelper_stripXWikiPrefix(title);

	const api = spiHelper_getAPI(title);
	try {
		await api.postWithToken('csrf', {
			action: 'purge',
			titles: strippedTitle
		});
		$statusLine.html('Purged ' + $link.prop('outerHTML'));
	} catch (error) {
		$statusLine.addClass('spihelper-errortext').html('<b>Failed to purge ' + $link.prop('outerHTML') + '</b>: ' + 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<boolean>} True if the block suceeded, false if not
 */
async function spiHelper_blockUser(user, duration, reason, reblock, anononly, accountcreation,
	autoblock, talkpage, email, watchBlockedUser, watchExpiry) {
	'use strict';
	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'));
		return true;
	} catch (error) {
		$statusLine.addClass('spihelper-errortext').html('<b>Failed to block ' + $link.prop('outerHTML') + '</b>: ' + error);
		return false;
	}
}

/**
 * Get whether a user is currently blocked
 *
 * @param {string} user Username
 * @return {Promise<string>} Block reason, empty string if not blocked
 */
async function spiHelper_getUserBlockReason(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<boolean>} Whether the user is globally locked
 */
async function spiHelper_isUserGloballyLocked(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<number>} Latest revision of a page, 0 if it doesn't exist
 */
async function spiHelper_getPageRev(title) {
	'use strict';

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

	try {
		const response = await spiHelper_getAPI(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 spiHelper_deletePage(title, reason) {
	'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('Deleting ' + $link.prop('outerHTML'));

	const api = spiHelper_getAPI(title);
	try {
		await api.postWithToken('csrf', {
			action: 'delete',
			title: title,
			reason: reason
		});
		$statusLine.html('Deleted ' + $link.prop('outerHTML'));
	} catch (error) {
		$statusLine.addClass('spihelper-errortext').html('<b>Failed to delete ' + $link.prop('outerHTML') + '</b>: ' + error);
	}
}

/**
 * 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 spiHelper_undeletePage(title, reason) {
	'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('Undeleting ' + $link.prop('outerHTML'));

	const api = spiHelper_getAPI(title);
	try {
		await api.postWithToken('csrf', {
			action: 'undelete',
			title: title,
			reason: reason
		});
		$statusLine.html('Undeleted ' + $link.prop('outerHTML'));
	} catch (error) {
		$statusLine.addClass('spihelper-errortext').html('<b>Failed to undelete ' + $link.prop('outerHTML') + '</b>: ' + error);
	}
}

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

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

	try {
		const response = await spiHelper_getAPI(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 spiHelper_getInvestigationSectionIDs() {
	// 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: spiHelper_pageName
	});
	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 spiHelper_getArchiveName() {
	return spiHelper_pageName + '/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 spiHelper_generateBlockTableLine(name, defaultblock, id) {
	'use strict';

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

	const $row = $('<tr>');
	// Username
	$('<td>').append($('<input>').attr('type', 'text').attr('id', 'spiHelper_block_username' + id)
		.val(name).addClass('.spihelper-widthlimit')).appendTo($row);
	// Block checkbox (only for admins)
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
		.attr('id', 'spiHelper_block_doblock' + id).prop('checked', defaultblock)).appendTo($row);
	// Block duration (only for admins)
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'text')
		.attr('id', 'spiHelper_block_duration' + id).val('indefinite')
		.addClass('.spihelper-widthlimit')).appendTo($row);
	// Account creation blocked (only for admins)
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
		.attr('id', 'spiHelper_block_acb' + id).prop('checked', true)).appendTo($row);
	// Autoblock (only for admins)
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
		.attr('id', 'spiHelper_block_ab' + id).prop('checked', true)).appendTo($row);
	// Revoke talk page access (only for admins)
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
		.attr('id', 'spiHelper_block_tp' + id)).appendTo($row);
	// Block email access (only for admins)
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
		.attr('id', 'spiHelper_block_email' + id)).appendTo($row);
	// Tag select box
	$('<td>').append($('<select>').attr('id', 'spiHelper_block_tag' + id)
		.val(name)).appendTo($row);
	// Altmaster tag select
	$('<td>').append($('<select>').attr('id', 'spiHelper_block_tag_altmaster' + id)
		.val(name)).appendTo($row);
	// Global lock (disabled for IPs since they can't be locked)
	$('<td>').append($('<input>').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
	spiHelper_generateSelect('spiHelper_block_tag' + id, spiHelper_TAG_OPTIONS);
	spiHelper_generateSelect('spiHelper_block_tag_altmaster' + id, spiHelper_ALTMASTER_TAG_OPTIONS);
}

/**
 * Complicated function to decide what checkboxes to enable or disable
 * and which to check by default
 */
async function spiHelper_setCheckboxesBySection() {
	// 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') {
		spiHelper_sectionId = null;
		spiHelper_sectionName = null;
	} else {
		spiHelper_sectionId = parseInt($('#spiHelper_sectionSelect', $topView).val().toString());
		const $sectionSelect = $('#spiHelper_sectionSelect', $topView);
		spiHelper_sectionName = spiHelper_caseSections[$sectionSelect.prop('selectedIndex')].line;
	}

	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);

	// 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);
	

	if (spiHelper_sectionId === null) {
		// "Block" "Rename" and "Archive" are enabled if we're using the "select all" option
		$archiveBox.prop('disabled', false);
		$blockBox.prop('disabled', false);
		// Force movebox to enabled in case it was disabled in the section view
		$moveBox.prop('disabled', false);

		// Everything else is disabled
		$closeBox.prop('disabled', true);
		$commentBox.prop('disabled', true);
		$caseActionBox.prop('disabled', true);
		$('#spiHelper_moveLabel', $topView).text('Move/merge full case (Clerk only)');

	} else {
		const sectionText = await spiHelper_getPageText(spiHelper_pageName, false, spiHelper_sectionId);
		if (!spiHelper_SECTION_RE.test(sectionText)) {
			// Nothing to do here.
			return;
		}
		const result = spiHelper_CASESTATUS_RE.exec(sectionText);
		let casestatus = '';
		if (result) {
			casestatus = result[1];
		}

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

		const isClosed = spiHelper_CASESTATUS_CLOSED_RE.test(casestatus);
		$caseActionBox.prop('disabled', false);
		$archiveBox.prop('disabled', false);
		$blockBox.prop('disabled', false);
		$closeBox.prop('disabled', false);
		$commentBox.prop('disabled', false);

		if (isClosed) {
			$closeBox.prop('disabled', true);
			$archiveBox.prop('disabled', false);
			$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"><b>READ ME FIRST</b></span>)');
	}
}

/**
 * Updates whether the 'archive' checkbox is enabled
 */
function spiHelper_updateArchive() {
	// 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 spiHelper_updateMove() {
	// 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 spiHelper_generateSelect(id, options) {
	// Add the dates to the selector
	const $selector = $('#' + id, document);
	for (let i = 0; i < options.length; i++) {
		const o = options[i];
		$('<option>')
			.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-use 'block' elements
 *
 * @param {JQuery<HTMLElement>} source The HTML input element that we're matching all selections to
 */
function spiHelper_setAllBlockOpts(source) {
	'use strict';
	for (let i = 1; i <= spiHelper_usercount; i++) {
		if (source.attr('type') === 'checkbox') {
			if (!source.prop('disabled')) {
				$('#' + source.attr('id') + i).prop('checked', source.prop('checked'));
			}
		} else {
			$('#' + source.attr('id') + i).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 spiHelper_insertTextFromSelect(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 {{note}} template at the start of the text box
 *
 * @param {JQuery<HTMLElement>} source Select box that was changed
 */
function spiHelper_insertNote(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*)?({{[\w\s]*note[\w\s]*}}\s*)?/i, '* ' + '{{' + source.val() + '}} ');
	$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 spiHelper_caseActionUpdated(source) {
	const $textBox = $('#spiHelper_CommentText', document);
	const oldText = $textBox.val().toString();
	let newTemplate = '';
	switch (source.val()) {
		case 'CUrequest':
			newTemplate = '{{CURequest}}';
			break;
		case 'admin':
			newTemplate = '{{awaitingadmin}}';
			break;
		case 'clerk':
			newTemplate = '{{Clerk Request}}';
			break;
		case 'selfendorse':
			newTemplate = '{{Requestandendorse}}';
			break;
		case 'inprogress':
			newTemplate = '{{Inprogress}}';
			break;
		case 'decline':
			newTemplate = '{{Decline}}';
			break;
		case 'cudecline':
			newTemplate = '{{Cudecline}}';
			break;
		case 'endorse':
			newTemplate = '{{Endorse}}';
			break;
		case 'cuendorse':
			newTemplate = '{{cuendorse}}';
			break;
		case 'moreinfo': // Intentional fallthrough
		case 'cumoreinfo':
			newTemplate = '{{moreinfo}}';
			break;
		case 'relist':
			newTemplate = '{{relisted}}';
			break;
		case 'hold':
		case 'cuhold':
			newTemplate = '{{onhold}}';
			break;
	}
	if (spiHelper_CLERKSTATUS_RE.test(oldText)) {
		$textBox.val(oldText.replace(spiHelper_CLERKSTATUS_RE, 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 spiHelper_addLink() {
	'use strict';
	await spiHelper_loadSettings();
	await mw.loader.load('mediawiki.util');
	const initLink = mw.util.addPortletLink('p-cactions', '#', 'SPI', 'ca-spiHelper');
	initLink.addEventListener('click', (e) => {
		e.preventDefault();
		return spiHelper_init();
	});
	if (mw.config.get('wgCategories').includes('SPI cases awaiting archive') && spiHelper_isClerk()) {
		const oneClickArchiveLink = mw.util.addPortletLink('p-cactions', '#', 'SPI-Archive', 'ca-spiHelperArchive');
		oneClickArchiveLink.addEventListener('click', (e) => {
			e.preventDefault();
			return spiHelper_oneClickArchive();
		});
	}
	window.addEventListener('beforeunload', (e) => {
		const $actionView = $('#spiHelper_actionViewDiv', document);
		if ($actionView.length > 0) {
			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 spiHelper_loadSettings() {
	// 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 ]) => {
				spiHelper_settings[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 spiHelper_isAdmin() {
	if (spiHelper_settings.debugForceAdminState !== null) {
		return spiHelper_settings.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 spiHelper_isCheckuser() {
	if (spiHelper_settings.debugForceCheckuserState !== null) {
		return spiHelper_settings.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 spiHelper_isClerk() {
	// Assumption: checkusers should see clerk options. Please don't prove this wrong.
	return spiHelper_settings.clerk || spiHelper_isCheckuser();
}

/**
 * Common username normalization function
 * @param {string} username Username to normalize
 *
 * @return {string} Normalized username
 */
function spiHelper_normalizeUsername(username) {
	// Replace underscores with spaces
	username = username.replace('_', ' ');
	// Get rid of bad hidden characters
	username = username.replace(spiHelper_HIDDEN_CHAR_NORM_RE, '');
	// 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;
}
// </nowiki>