Jump to content

User:DeltaQuadBot/sciptcopytest.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/* 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: '{{IPstale}}' },
	{ label: 'No comment (IP)', selected: false, value: '{{ncip}}'}
];

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

const spiHelper_ARCHIVENOTICE_RE = /{{\s*SPI\s*archive notice\|.*}}/i;

const spiHelper_PRIORCASES_RE = /{{spipriorcases}}/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(spiHelper_ARCHIVENOTICE_RE, '{{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(spiHelper_ARCHIVENOTICE_RE, '');
	newText = newText.replace(spiHelper_PRIORCASES_RE, '');
	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;
	const oldArchiveName = spiHelper_getArchiveName();
	spiHelper_caseName = target;
	spiHelper_pageName = newPageName;
	let archivesCopied = false;
	if (targetPageText) {
		// There's already a page there, we're going to merge
		// First, check if there's an archive; if so, copy its text over
		const newArchiveName = spiHelper_getArchiveName().replace(spiHelper_caseName, target);
		let sourceArchiveText = await spiHelper_getPageText(oldArchiveName, false);
		let targetArchiveText = await spiHelper_getPageText(newArchiveName, false);
		if (sourceArchiveText && targetArchiveText) {
			$('<li>')
			.append($('<div>').text('Archive detected on both source and target cases, manually copying archive.'))
			.appendTo($('#spiHelper_status', document));

			// Normalize the source archive text
			sourceArchiveText = sourceArchiveText.replace(/^\s*__TOC__\s*$\n/gm, '');
			sourceArchiveText = sourceArchiveText.replace(spiHelper_ARCHIVENOTICE_RE, '');
			sourceArchiveText = sourceArchiveText.replace(spiHelper_PRIORCASES_RE, '');
			// Strip leading newlines
			sourceArchiveText = sourceArchiveText.replace(/^\n*/, '');
			targetArchiveText += '\n' + sourceArchiveText;
			await spiHelper_editPage(newArchiveName, targetArchiveText, 'Copying archives from [[' + spiHelper_getInterwikiPrefix() + oldArchiveName + ']], see page history for attribution',
				false, spiHelper_settings.watchArchive, spiHelper_settings.watchArchiveExpiry);
			await spiHelper_deletePage(oldArchiveName, 'Deleting copied archive');
			archivesCopied = true;
		}
		// Ignore warnings on the move, we're going to get one since we're stomping an existing page
		await 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');
		if (archivesCopied) {
			// Create a redirect
			spiHelper_editPage(oldArchiveName, '#REDIRECT [[' + newArchiveName + ']]', 'Redirecting old archive to new archive',
				false, spiHelper_settings.watchArchive, spiHelper_settings.watchArchiveExpiry);
		}
	} 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);
	}
	if (archivesCopied) {
		alert('Archives were merged during the case move, please reorder the archive sections');
	}
}

/**
 * Move or merge a specific section of a case into a different case
 *
 * @param {string} target The username portion of the case this section should be merged into (pre-normalized)
 * @param {!number} sectionId The section ID of this case that should be moved/merged
 */
async function 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)
	const defaultBlockDuration = mw.util.isIPAddress(name, true) ? '1 week' : 'indefinite';
	$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'text')
		.attr('id', 'spiHelper_block_duration' + id).val(defaultBlockDuration)
		.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-user '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++) {
		const target = $('#' + source.attr('id') + i);
		if (source.attr('type') === 'checkbox') {
			// Don't try to set disabled checkboxes
			if (!target.prop('disabled')) {
				target.prop('checked', source.prop('checked'));
			}
		} else {
			target.val(source.val());
		}
	}
}

/**
 * Inserts text at the cursor's position
 *
 * @param {JQuery<HTMLElement>} source Select box that was changed
 * @param {number?} pos Position to insert text; if null, inserts at the cursor
 */
function 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>