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