User:GeneralNotability/spihelper.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. |
This user script seems to have a documentation page at User:GeneralNotability/spihelper and an accompanying .css page at User:GeneralNotability/spihelper.css. |
// <nowiki>
// @ts-check
// GeneralNotability's rewrite of Tim's SPI helper script
// With contributions from 0xDeadbeef, DatGuy, Dreamy Jazz, L235, Tamzin, TheresNoTime, and Xiplus
// v2.9.1 "No sorcery threats"
/* global mw, $, importStylesheet, importScript, displayMessage, spiHelperCustomOpts */
// Adapted from [[User:Mr.Z-man/closeAFD]]
importStylesheet('User:GeneralNotability/spihelper-dev.css')
importScript('User:Timotheus Canens/displaymessage.js')
// Typedefs
/**
* @typedef SelectOption
* @type {Object}
* @property {string} label Text to display in the drop-down
* @property {string} value Value to return if this option is selected
* @property {boolean} selected Whether this item should be selected by default
* @property {boolean=} disabled Whether this item should be disabled
*/
/**
* @typedef BlockEntry
* @type {Object}
* @property {string} username Username to block
* @property {string} duration Duration of block
* @property {boolean} acb If set, account creation is blocked
* @property {boolean} ab Whether autoblock is enabled (for registered users)/
* logged-in users are blocked (for IPs)
* @property {boolean} ntp If set, talk page access is blocked
* @property {boolean} nem If set, email access is blocked
* @property {string} tpn Type of talk page notice to apply on block
*/
/**
* @typedef TagEntry
* @type {Object}
* @property {string} username Username to tag
* @property {string} tag Tag to apply to user
* @property {string} altmasterTag Altmaster tag to apply to user, if relevant
* @property {boolean} blocking Whether this account is marked for block as well
*/
/**
* @typedef ParsedArchiveNotice
* @type {Object}
* @property {string} username Case username
* @property {boolean} xwiki Whether the crosswiki flag is set
* @property {boolean} deny Whether the deny flag is set
* @property {boolean} notalk Whether the notalk flag is set
*/
// Globals
/* User setting related globals */
// User-configurable settings, these are the defaults but will be updated by
// spiHelperLoadSettings()
const spiHelperSettings = {
// Choices are 'watch' (unconditionally add to watchlist), 'preferences'
// (follow default preferences), 'nochange' (don't change the watchlist
// status of the page), and 'unwatch' (unconditionally remove)
watchCase: 'preferences',
watchCaseExpiry: 'indefinite',
watchArchive: 'nochange',
watchArchiveExpiry: 'indefinite',
watchTaggedUser: 'preferences',
watchTaggedUserExpiry: 'indefinite',
watchNewCats: 'nochange',
watchNewCatsExpiry: 'indefinite',
watchBlockedUser: true,
watchBlockedUserExpiry: 'indefinite',
// Lets people disable clerk options if they're not a clerk
clerk: true,
// Log all actions to Special:MyPage/spihelper_log
log: false,
// Reverse said log, so that the newest actions are at the top.
reversed_log: false,
// Enable the "move section" button
iUnderstandSectionMoves: false,
// Automatically tick the "Archive case" option if the case is closed
tickArchiveWhenCaseClosed: true,
// Use checkuserblock-account when CU blocking. False when not a CU, by default true when a CU
useCheckuserblockAccount: false,
// Default IPv6 listings to /64 in the block/tag socks menu
displayIPv6As64: true,
// 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
}
// Can't set in the spiHelperSettings declaration because spiHelperIsCheckuser itself uses the settings var
spiHelperSettings.useCheckuserblockAccount = spiHelperIsCheckuser()
// Valid options for spiHelperSettings. Prevents invalid setting options being specified in the spioptions user subpage.
// This method only works options with discrete possible values. Settings without discrete possible values are checked for in spiHelperLoadSettings().
const spiHelperValidSettings = {
watchCase: ['preferences', 'watch', 'nochange', 'unwatch'],
watchArchive: ['preferences', 'watch', 'nochange', 'unwatch'],
watchTaggedUser: ['preferences', 'watch', 'nochange', 'unwatch'],
watchNewCats: ['preferences', 'watch', 'nochange', 'unwatch'],
watchBlockedUser: [true, false],
clerk: [true, false],
log: [true, false],
reversed_log: [true, false],
iUnderstandSectionMoves: [true, false],
tickArchiveWhenCaseClosed: [true, false],
useCheckuserblockAccount: [true, false],
debugForceCheckuserState: [null, true, false],
debugForceAdminState: [null, true, false]
}
// These user settings must be a valid date as defined by MediaWiki API. This is checked for in spiHelperValidateDate() via spiHelperLoadSettings()
const spiHelperSettingsNeedingValidDate = [
'watchCaseExpiry',
'watchArchiveExpiry',
'watchTaggedUserExpiry',
'watchNewCatsExpiry',
'watchBlockedUserExpiry'
]
/* Globals to describe the current SPI page */
/** @type {string} Name of the SPI page in wiki title form
* (e.g. Wikipedia:Sockpuppet investigations/Test) */
let spiHelperPageName = mw.config.get('wgPageName').replace(/_/g, ' ')
/** @type {number} The main page's ID - used to check if the page
* has been edited since we opened it to prevent edit conflicts
*/
let spiHelperStartingRevID = mw.config.get('wgCurRevisionId')
const spiHelperIsThisPageAnArchive = mw.config.get('wgPageName').match('Wikipedia:Sockpuppet_investigations/.*/Archive.*')
/** @type {string} Just the username part of the case */
let spiHelperCaseName
if (spiHelperIsThisPageAnArchive) {
spiHelperCaseName = spiHelperPageName.replace(/Wikipedia:Sockpuppet investigations\//g, '').replace(/\/Archive/, '')
} else {
spiHelperCaseName = spiHelperPageName.replace(/Wikipedia:Sockpuppet investigations\//g, '')
}
/** list of section IDs + names corresponding to separate investigations */
let spiHelperCaseSections = []
/** @type {?number} Selected section, "null" means that we're opearting on the entire page */
let spiHelperSectionId = null
/** @type {?string} Selected section's name (e.g. "10 June 2020") */
let spiHelperSectionName = null
/** @type {ParsedArchiveNotice} */
let spiHelperArchiveNoticeParams
/** Map of top-level actions the user has selected */
const spiHelperActionsSelected = {
Case_act: false,
Block: false,
Links: false,
Note: false,
Close: false,
Rename: false,
Archive: false,
SpiMgmt: false
}
/** @type {BlockEntry[]} Requested blocks */
const spiHelperBlocks = []
/** @type {TagEntry[]} Requested tags */
const spiHelperTags = []
/** @type {string[]} Requested global locks */
const spiHelperGlobalLocks = []
// Count of unique users in the case (anything with a checkuser, checkip, user, ip, or vandal template on the page) for the block view
let spiHelperBlockTableUserCount = 0
// Count of unique users in the case (anything with a checkuser, checkip, user, ip, or vandal template on the page) for the link view (seperate needed as extra rows can be added)
let spiHelperLinkTableUserCount = 0
// The current wiki's interwiki prefix
const spiHelperInterwikiPrefix = spiHelperGetInterwikiPrefix()
// Map of active operations (used as a "dirty" flag for beforeunload)
// Values are strings representing the state - acceptable values are 'running', 'success', 'failed'
const spiHelperActiveOperations = new Map()
/* Globals to describe possible options for dropdown menus */
/** @type {SelectOption[]} List of possible selections for tagging a user in the block/tag interface
*/
const spiHelperTagOptions = [
{ label: 'None', selected: true, value: '' },
{ label: 'Suspected sock', value: 'blocked', selected: false },
{ label: 'Proven sock', value: 'proven', selected: false },
{ label: 'CU confirmed sock', value: 'confirmed', selected: false },
{ label: 'Blocked master', value: 'master', selected: false },
{ label: 'CU confirmed master', value: 'sockmasterchecked', selected: false },
{ label: '3X banned master', value: 'bannedmaster', selected: false }
]
/** @type {SelectOption[]} List of possible selections for tagging a user's altmaster in the block/tag interface */
const spiHelperAltMasterTagOptions = [
{ label: 'None', selected: true, value: '' },
{ label: 'Suspected alt master', value: 'suspected', selected: false },
{ label: 'Proven alt master', value: 'proven', selected: false }
]
/** @type {SelectOption[]} List of templates that CUs might insert */
const spiHelperCUTemplates = [
{ label: 'CU templates', selected: true, value: '', disabled: true },
{ label: 'Confirmed', selected: false, value: '{{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 spiHelperAdminTemplates = [
{ label: 'Admin/clerk templates', selected: true, value: '', disabled: true },
{ label: 'Duck', selected: false, value: '{{duck}}' },
{ label: 'Megaphone Duck', selected: false, value: '{{megaphone duck}}' },
{ label: 'IP blocked', selected: false, value: '{{IPblock}}' },
{ 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: 'Requested actions completed, closing', selected: false, value: '{{Action and close}}' },
{ label: 'Closing without action', selected: false, value: '{{Closing without action}}' },
{ label: 'Diffs needed', selected: false, value: '{{DiffsNeeded|moreinfo}}' },
{ label: 'Locks requested', selected: false, value: '{{GlobalLocksRequested}}' }
]
/* Globals for regexes */
// Regex to match the case status, group 1 is the actual status
const spiHelperCaseStatusRegex = /{{\s*SPI case status\s*\|?\s*(\S*?)\s*}}/i
// Regex to match closed case statuses (close or closed)
const spiHelperCaseClosedRegex = /^closed?$/i
const spiHelperClerkStatusRegex = /{{(CURequest|awaitingadmin|clerk ?request|(?:self|requestand|cu)?endorse|inprogress|decline(?:-ip)?|moreinfo|relisted|onhold)}}/i
const spiHelperSockSectionWithNewlineRegex = /====\s*Suspected sockpuppets\s*====\n*/i
const spiHelperAdminSectionWithPrecedingNewlinesRegex = /\n*\s*====\s*<big>Clerk, CheckUser, and\/or patrolling admin comments<\/big>\s*====\s*/i
const spiHelperCUBlockRegex = /{{(checkuserblock(-account|-wide)?|checkuser block)}}/i
const spiHelperArchiveNoticeRegex = /{{\s*SPI\s*archive notice\|(?:1=)?([^|]*?)(\|.*)?}}/i
const spiHelperPriorCasesRegex = /{{spipriorcases}}/i
const spiHelperSectionRegex = /^(?:===[^=]*===|=====[^=]*=====)\s*$/m
// regex to remove hidden characters from form inputs - they mess up some things,
// especially mw.util.isIP
const spiHelperHiddenCharNormRegex = /\u200E/g
/* Other globals */
/** @type{string} Advert to append to the edit summary of edits */
const spihelperAdvert = ' (using [[:w:en:WP:SPIH|spihelper.js]])'
/* Used by the link view */
const spiHelperLinkViewURLFormats = {
editorInteractionAnalyser: { baseurl: 'https://sigma.toolforge.org/editorinteract.py', appendToQueryString: '', userQueryStringKey: 'users', userQueryStringSeparator: '&', userQueryStringWrapper: '', multipleUserQueryStringKeys: true, name: 'Editor Interaction Anaylser' },
interactionTimeline: { baseurl: 'https://interaction-timeline.toolforge.org/', appendToQueryString: 'wiki=enwiki', userQueryStringKey: 'user', userQueryStringSeparator: '&', userQueryStringWrapper: '', multipleUserQueryStringKeys: true, name: 'Interaction Timeline' },
timecardSPITools: { baseurl: 'https://spi-tools.toolforge.org/spi/timecard/' + spiHelperCaseName, appendToQueryString: '', userQueryStringKey: 'users', userQueryStringSeparator: '&', userQueryStringWrapper: '', multipleUserQueryStringKeys: true, name: 'Timecard comparisons' },
consolidatedTimelineSPITools: { baseurl: 'https://spi-tools.toolforge.org/spi/timecard/' + spiHelperCaseName, appendToQueryString: '', userQueryStringKey: 'users', userQueryStringSeparator: '&', userQueryStringWrapper: '', multipleUserQueryStringKeys: true, name: 'Consolidated Timeline (requires login)' },
pagesSPITools: { baseurl: 'https://spi-tools.toolforge.org/spi/timeline/' + spiHelperCaseName, appendToQueryString: '', userQueryStringKey: 'users', userQueryStringSeparator: '&', userQueryStringWrapper: '', multipleUserQueryStringKeys: true, name: 'SPI Tools Pages (requires login)' },
checkUserWikiSearch: { baseurl: 'https://checkuser.wikimedia.org/w/index.php', appendToQueryString: 'ns0=1', userQueryStringKey: 'search', userQueryStringSeparator: ' OR ', userQueryStringWrapper: '"', multipleUserQueryStringKeys: false, name: 'Checkuser wiki search' }
}
/* 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.loader.load('mediawiki.user')
$(spiHelperAddLink)
}
// Main functions - do the meat of the processing and UI work
const spiHelperTopViewHTML = `
<div id="spiHelper_topViewDiv">
<h3>Handling SPI case</h3>
<select id="spiHelper_sectionSelect"></select>
<h4 id="spiHelper_warning" class="spihelper-errortext" hidden></h4>
<ul>
<li id="spiHelper_actionLine" class="spiHelper_singleCaseOnly spiHelper_notOnArchive">
<input type="checkbox" name="spiHelper_Case_Action" id="spiHelper_Case_Action" />
<label for="spiHelper_Case_Action">Change case status</label>
</li>
<li id="spiHelper_spiMgmtLine" class="spiHelper_allCasesOnly spiHelper_notOnArchive">
<input type="checkbox" id="spiHelper_SpiMgmt" />
<label for="spiHelper_SpiMgmt">Change SPI options</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_userInfoLine" class="spiHelper_singleCaseOnly">
<input type="checkbox" name="spiHelper_userInfo" id="spiHelper_userInfo" />
<label for="spiHelper_userInfo">Sock links</label>
</li>
<li id="spiHelper_commentLine" class="spiHelper_singleCaseOnly spiHelper_notOnArchive">
<input type="checkbox" name="spiHelper_Comment" id="spiHelper_Comment" />
<label for="spiHelper_Comment">Note/comment</label>
</li>
<li id="spiHelper_closeLine" class="spiHelper_adminClerkClass spiHelper_singleCaseOnly spiHelper_notOnArchive">
<input type="checkbox" name="spiHelper_Close" id="spiHelper_Close" />
<label for="spiHelper_Close">Close case</label>
</li>
<li id="spiHelper_moveLine" class="spiHelper_clerkClass spiHelper_notOnArchive">
<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 spiHelper_notOnArchive">
<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" />
</div>
`
/**
* Initialization functions for spiHelper, displays the top-level menu
*/
async function spiHelperInit () {
'use strict'
spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs()
// Load archivenotice params
spiHelperArchiveNoticeParams = await spiHelperParseArchiveNotice(spiHelperPageName.replace(/\/Archive/, ''))
// First, insert the template text
displayMessage(spiHelperTopViewHTML)
// Narrow search scope
const $topView = $('#spiHelper_topViewDiv', document)
updateForRole($topView)
if (spiHelperArchiveNoticeParams.username === null) {
// No archive notice was found
const $warningText = $('#spiHelper_warning', $topView)
$warningText.show()
$warningText.append($('<b>').text('Can\'t find archivenotice template! Automatically adding the archive notice to the page.'))
const newArchiveNotice = spiHelperMakeNewArchiveNotice(spiHelperCaseName, { xwiki: false, deny: false, notalk: false })
let pagetext = await spiHelperGetPageText(spiHelperPageName, false)
if (spiHelperPriorCasesRegex.exec(pagetext) === null) {
pagetext = '{{SPIpriorcases}}\n' + pagetext
}
pagetext = newArchiveNotice + '\n' + pagetext
if (pagetext.indexOf('__TOC__') === -1) {
pagetext = '<noinclude>__TOC__</noinclude>\n' + pagetext
}
await spiHelperEditPage(spiHelperPageName, pagetext, 'Adding archive notice', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)
}
// Next, modify what's displayed
// Set the block selection label based on whether or not the user is an admin
$('#spiHelper_blockLabel', $topView).text(spiHelperIsAdmin() ? 'Block/tag socks' : 'Tag socks')
// Wire up a couple of onclick handlers
$('#spiHelper_Move', $topView).on('click', function () {
spiHelperUpdateArchive()
})
$('#spiHelper_Archive', $topView).on('click', function () {
spiHelperUpdateMove()
})
// Generate the section selector
const $sectionSelect = $('#spiHelper_sectionSelect', $topView)
$sectionSelect.on('change', () => {
spiHelperSetCheckboxesBySection()
})
// Add the dates to the selector
for (let i = 0; i < spiHelperCaseSections.length; i++) {
const s = spiHelperCaseSections[i]
$('<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)
// Only show options suitable for the archive subpage when running on the archives
if (!spiHelperIsThisPageAnArchive) {
$('.spiHelper_notOnArchive', $topView).show()
}
// Set the checkboxes to their default states
spiHelperSetCheckboxesBySection()
$('#spiHelper_GenerateForm', $topView).one('click', () => {
spiHelperGenerateForm()
})
}
const spiHelperActionViewHTML = `
<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"></select>
</div>
<div id="spiHelper_spiMgmtView">
<h4>Changing SPI settings</h4>
<ul>
<li>
<input type="checkbox" id="spiHelper_spiMgmt_crosswiki" />
<label for="spiHelper_spiMgmt_crosswiki">Case is crosswiki</label>
</li>
<li>
<input type="checkbox" id="spiHelper_spiMgmt_deny" />
<label for="spiHelper_spiMgmt_deny">Socks should not be tagged per DENY</label>
</li>
<li>
<input type="checkbox" id="spiHelper_spiMgmt_notalk" />
<label for="spiHelper_spiMgmt_notalk">Socks should have talk page and email access revoked due to past abuse</label>
</li>
</ul>
</div>
<div id="spiHelper_sockLinksView">
<h4 id="spiHelper_sockLinksHeader">Useful links for socks</h4>
<table id="spiHelper_userInfoTable" style="border-collapse:collapse;">
<tr>
<th>Username</th>
<th><span title="Editor interaction analyser" class="rt-commentedText spihelper-hovertext">Interaction analyser</span></th>
<th><span title="Interaction timeline" class="rt-commentedText spihelper-hovertext">Interaction timeline</span></th>
<th><span title="Timecard comparison - SPI tools" class="rt-commentedText spihelper-hovertext">Timecard</span></th>
<th class="spiHelper_adminClass"><span title="Consolidated timeline (login needed) - SPI tools" class="rt-commentedText spihelper-hovertext">Consolidated timeline</span></th>
<th class="spiHelper_adminClass"><span title="Pages - SPI tools (login needed)" class="rt-commentedText spihelper-hovertext">Pages</span></th>
<th class="spiHelper_cuClass"><span title="CheckUser wiki search" class="rt-commentedText spihelper-hovertext">CU wiki</span></th>
</tr>
<tr style="border-bottom:2px solid black">
<td style="text-align:center;">(All users)</td>
<td style="text-align:center;"><input type="checkbox" id="spiHelper_link_editorInteractionAnalyser"/></td>
<td style="text-align:center;"><input type="checkbox" id="spiHelper_link_interactionTimeline"/></td>
<td style="text-align:center;"><input type="checkbox" id="spiHelper_link_timecardSPITools"/></td>
<td style="text-align:center;" class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_link_consolidatedTimelineSPITools"/></td>
<td style="text-align:center;" class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_link_pagesSPITools"/></td>
<td style="text-align:center;" class="spiHelper_adminClass"><input type="checkbox" id="spiHelper_link_checkUserWikiSearch"/></td>
</tr>
</table>
<span><input type="button" id="moreSerks" value="Add Row" /></span>
</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_clerkClass">
<input type="checkbox" checked="checked" name="spiHelper_tagAccountsWithoutLocalAccount" id="spiHelper_tagAccountsWithoutLocalAccount" />
<label for="spiHelper_tagAccountsWithoutLocalAccount">Tag accounts without an attached local account.</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" checked="checked" 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"></select></td>
<td><select id="spiHelper_block_tag_altmaster"></select></td>
<td><input type="checkbox" name="spiHelper_block_lock_all" id="spiHelper_block_lock"/></td>
</tr>
</table>
<span><input type="button" id="moreSerks" value="Add Row" /></span>
</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>
<select class="spiHelper_adminClerkClass" id="spiHelper_adminSelect"></select>
<select class="spiHelper_cuClass" id="spiHelper_cuSelect"></select>
</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>
</div>
<br>
<input type="button" id="spiHelper_performActions" value="Done" />
</div>
`
/**
* Big function to generate the SPI form from the top-level menu selections
*
* Would fail ESlint no-unused-vars due to only being
* referenced in an onclick event
*
* @return {Promise<void>}
*/
// eslint-disable-next-line no-unused-vars
async function spiHelperGenerateForm () {
'use strict'
spiHelperBlockTableUserCount = 0
spiHelperLinkTableUserCount = 0
const $topView = $('#spiHelper_topViewDiv', document)
spiHelperActionsSelected.Case_act = $('#spiHelper_Case_Action', $topView).prop('checked')
spiHelperActionsSelected.Block = $('#spiHelper_BlockTag', $topView).prop('checked')
spiHelperActionsSelected.Link = $('#spiHelper_userInfo', $topView).prop('checked')
spiHelperActionsSelected.Close = $('#spiHelper_Close', $topView).prop('checked')
spiHelperActionsSelected.Note = $('#spiHelper_Comment', $topView).prop('checked') || spiHelperActionsSelected.Case_act || spiHelperActionsSelected.Block || spiHelperActionsSelected.Close
spiHelperActionsSelected.Rename = $('#spiHelper_Move', $topView).prop('checked')
spiHelperActionsSelected.Archive = $('#spiHelper_Archive', $topView).prop('checked')
spiHelperActionsSelected.SpiMgmt = $('#spiHelper_SpiMgmt', $topView).prop('checked')
const pagetext = await spiHelperGetPageText(spiHelperPageName, false, spiHelperSectionId)
if (!(spiHelperActionsSelected.Case_act ||
spiHelperActionsSelected.Note || spiHelperActionsSelected.Close ||
spiHelperActionsSelected.Archive || spiHelperActionsSelected.Block || spiHelperActionsSelected.Link ||
spiHelperActionsSelected.Rename || spiHelperActionsSelected.SpiMgmt)) {
displayMessage('')
return
}
displayMessage(spiHelperActionViewHTML)
// Reduce the scope that jquery operates on
const $actionView = $('#spiHelper_actionViewDiv', document)
updateForRole($actionView)
// Wire up the action view
$('#spiHelper_backLink', $actionView).one('click', () => {
spiHelperInit()
})
if (spiHelperActionsSelected.Case_act) {
const result = spiHelperCaseStatusRegex.exec(pagetext)
let casestatus = ''
if (result) {
casestatus = result[1]
}
const canAddCURequest = (casestatus === '' || /^(?:admin|moreinfo|cumoreinfo|hold|cuhold|clerk|open)$/i.test(casestatus))
const cuRequested = /^(?:CU|checkuser|CUrequest|request|cumoreinfo)$/i.test(casestatus)
const cuEndorsed = /^(?:endorse(d)?)$/i.test(casestatus)
const cuCompleted = /^(?:inprogress|checking|relist(ed)?|checked|completed|declined?|cudeclin(ed)?)$/i.test(casestatus)
/** @type {SelectOption[]} Generated array of values for the case status select box */
const selectOpts = [
{ label: 'No action', value: 'noaction', selected: true }
]
if (spiHelperCaseClosedRegex.test(casestatus)) {
selectOpts.push({ label: 'Reopen', value: 'reopen', selected: false })
} else if (spiHelperIsClerk() && casestatus === 'clerk') {
// Allow clerks to change the status from clerk to open.
// Used when clerk assistance has been given and the case previously had the status 'open'.
selectOpts.push({ label: 'Mark as open', value: 'open', selected: false })
} else if (spiHelperIsAdmin() && casestatus === 'admin') {
// Allow admins to change the status to open from admin
// Used when admin assistance has been given to the non-admin clerk and the case previously had the status 'open'.
selectOpts.push({ label: 'Mark as open', value: 'open', selected: false })
}
if (spiHelperIsCheckuser()) {
selectOpts.push({ label: 'Mark as in progress', value: 'inprogress', selected: false })
}
if (spiHelperIsClerk() || spiHelperIsAdmin()) {
selectOpts.push({ label: 'Request more information', value: 'moreinfo', selected: false })
}
if (canAddCURequest) {
// Statuses only available if the case could be moved to "CU requested"
selectOpts.push({ label: 'Request CU', value: 'CUrequest', selected: false })
if (spiHelperIsClerk()) {
selectOpts.push({ label: 'Request CU and self-endorse', value: 'selfendorse', selected: false })
}
}
// CU already requested
if (cuRequested && spiHelperIsClerk()) {
// Statuses only available if CU has been requested, only clerks + CUs should use these
selectOpts.push({ label: 'Endorse for CU attention', value: 'endorse', selected: false })
// Switch the decline option depending on whether the user is a checkuser
if (spiHelperIsCheckuser()) {
selectOpts.push({ label: 'Endorse CU as a CheckUser', value: 'cuendorse', selected: false })
}
if (spiHelperIsCheckuser()) {
selectOpts.push({ label: 'Decline CU', value: 'cudecline', selected: false })
} else {
selectOpts.push({ label: 'Decline CU', value: 'decline', selected: false })
}
selectOpts.push({ label: 'Request more information for CU', value: 'cumoreinfo', selected: false })
} else if (cuEndorsed && spiHelperIsCheckuser()) {
// Let checkusers decline endorsed cases
if (spiHelperIsCheckuser()) {
selectOpts.push({ label: 'Decline CU', value: 'cudecline', selected: false })
}
selectOpts.push({ label: 'Request more information for CU', value: 'cumoreinfo', selected: false })
}
// This is mostly a CU function, but let's let clerks and admins set it
// in case the CU forgot (or in case we're un-closing))
if (spiHelperIsAdmin() || spiHelperIsClerk()) {
selectOpts.push({ label: 'Mark as checked', value: 'checked', selected: false })
}
if (spiHelperIsClerk() && cuCompleted) {
selectOpts.push({ label: 'Relist for another check', value: 'relist', selected: false })
}
if (spiHelperIsCheckuser()) {
selectOpts.push({ label: 'Place case on CU hold', value: 'cuhold', selected: false })
} else { // I guess it's okay for anyone to have this option
selectOpts.push({ label: 'Place case on hold', value: 'hold', selected: false })
}
selectOpts.push({ label: 'Request clerk action', value: 'clerk', selected: false })
// I think this is only useful for non-admin clerks to ask admins to do stuff
if (!spiHelperIsAdmin() && spiHelperIsClerk()) {
selectOpts.push({ label: 'Request admin action', value: 'admin', selected: false })
}
// Generate the case action options
spiHelperGenerateSelect('spiHelper_CaseAction', selectOpts)
// Add the onclick handler to the drop-down
$('#spiHelper_CaseAction', $actionView).on('change', function (e) {
spiHelperCaseActionUpdated($(e.target))
})
$('#spiHelper_actionView', $actionView).show()
}
if (spiHelperActionsSelected.SpiMgmt) {
const $xwikiBox = $('#spiHelper_spiMgmt_crosswiki', $actionView)
const $denyBox = $('#spiHelper_spiMgmt_deny', $actionView)
const $notalkBox = $('#spiHelper_spiMgmt_notalk', $actionView)
$xwikiBox.prop('checked', spiHelperArchiveNoticeParams.xwiki)
$denyBox.prop('checked', spiHelperArchiveNoticeParams.deny)
$notalkBox.prop('checked', spiHelperArchiveNoticeParams.notalk)
$('#spiHelper_spiMgmtView', $actionView).show()
}
if (spiHelperActionsSelected.Close) {
$('#spiHelper_closeView', $actionView).show()
}
if (spiHelperActionsSelected.Archive) {
$('#spiHelper_archiveView', $actionView).show()
}
// Only give the option to comment if we selected a specific section and we are not running on an archive subpage
if (spiHelperSectionId && spiHelperActionsSelected.Note && !spiHelperIsThisPageAnArchive) {
// generate the note prefixes
/** @type {SelectOption[]} */
const spiHelperNoteTemplates = [
{ label: 'Comment templates', selected: true, value: '', disabled: true }
]
if (spiHelperIsClerk()) {
spiHelperNoteTemplates.push({ label: 'Clerk note', selected: false, value: 'clerknote' })
}
if (spiHelperIsAdmin()) {
spiHelperNoteTemplates.push({ label: 'Administrator note', selected: false, value: 'adminnote' })
}
if (spiHelperIsCheckuser()) {
spiHelperNoteTemplates.push({ label: 'CU note', selected: false, value: 'cunote' })
}
spiHelperNoteTemplates.push({ label: 'Note', selected: false, value: 'takenote' })
// Wire up the select boxes
spiHelperGenerateSelect('spiHelper_noteSelect', spiHelperNoteTemplates)
$('#spiHelper_noteSelect', $actionView).on('change', function (e) {
spiHelperInsertNote($(e.target))
})
spiHelperGenerateSelect('spiHelper_adminSelect', spiHelperAdminTemplates)
$('#spiHelper_adminSelect', $actionView).on('change', function (e) {
spiHelperInsertTextFromSelect($(e.target))
})
spiHelperGenerateSelect('spiHelper_cuSelect', spiHelperCUTemplates)
$('#spiHelper_cuSelect', $actionView).on('change', function (e) {
spiHelperInsertTextFromSelect($(e.target))
})
$('#spiHelper_previewLink', $actionView).on('click', function () {
spiHelperPreviewText()
})
$('#spiHelper_commentView', $actionView).show()
}
if (spiHelperActionsSelected.Rename) {
if (spiHelperSectionId) {
$('#spiHelper_moveHeader', $actionView).text('Move section "' + spiHelperSectionName + '"')
} else {
$('#spiHelper_moveHeader', $actionView).text('Move/merge full case')
}
$('#spiHelper_moveView', $actionView).show()
}
if (spiHelperActionsSelected.Block || spiHelperActionsSelected.Link) {
// eslint-disable-next-line no-useless-escape
const checkuserRegex = /{{\s*check(?:user|ip)\s*\|\s*(?:1=)?\s*([^\|}]*?)\s*(?:\|master name\s*=\s*.*)?}}/gi
const results = pagetext.match(checkuserRegex)
const likelyusers = []
const likelyips = []
const possibleusers = []
const possibleips = []
likelyusers.push(spiHelperCaseName)
if (results) {
for (let i = 0; i < results.length; i++) {
const username = spiHelperNormalizeUsername(results[i].replace(checkuserRegex, '$1'))
const isIP = mw.util.isIPAddress(username, true)
if (!isIP && !likelyusers.includes(username)) {
likelyusers.push(username)
} else if (isIP && !likelyips.includes(username)) {
if (spiHelperSettings.displayIPv6As64 && mw.util.isIPv6Address(username, false)) {
likelyips.push(username.split(':').slice(0, 4).concat('0', '0', '0', '0').join(':') + '/64')
continue
}
likelyips.push(username)
}
}
}
const unnamedParameterRegex = /^\s*\d+\s*$/i
const socklistResults = pagetext.match(/{{\s*sock\s?list\s*([^}]*)}}/gi)
if (socklistResults) {
for (let i = 0; i < socklistResults.length; i++) {
const socklistMatch = socklistResults[i].match(/{{\s*sock\s?list\s*([^}]*)}}/i)[1]
// First split the text into parts based on the presence of a |
const socklistArguments = socklistMatch.split('|')
for (let j = 0; j < socklistArguments.length; j++) {
// Now try to split based on "=", if wasn't able to it means it's an unnamed argument
const splitArgument = socklistArguments[j].split('=')
let username = ''
if (splitArgument.length === 1) {
username = spiHelperNormalizeUsername(splitArgument[0])
} else if (unnamedParameterRegex.test(splitArgument[0])) {
username = spiHelperNormalizeUsername(splitArgument.slice(1).join('='))
}
if (username !== '') {
const isIP = mw.util.isIPAddress(username, true)
if (isIP && !likelyips.includes(username)) {
if (spiHelperSettings.displayIPv6As64 && mw.util.isIPv6Address(username, false)) {
likelyips.push(username.split(':').slice(0, 4).concat('0', '0', '0', '0').join(':') + '/64')
continue
}
likelyips.push(username)
} else if (!isIP && !likelyusers.includes(username)) {
likelyusers.push(username)
}
}
}
}
}
// eslint-disable-next-line no-useless-escape
const userRegex = /{{[^\|}{]*?(?:user|vandal|IP|noping)[^\|}{]*?\|\s*(?:1=)?\s*([^\|}]*?)\s*}}/gi
const userresults = pagetext.match(userRegex)
if (userresults) {
for (let i = 0; i < userresults.length; i++) {
const username = spiHelperNormalizeUsername(userresults[i].replace(userRegex, '$1'))
const isIP = mw.util.isIPAddress(username, true)
if (isIP && !possibleips.includes(username) &&
!likelyips.includes(username)) {
possibleips.push(username)
} else if (!isIP && !possibleusers.includes(username) &&
!likelyusers.includes(username)) {
possibleusers.push(username)
}
}
}
if (spiHelperActionsSelected.Block) {
// Show generation in progress so not to make people think its broken
$('#spiHelper_blockTagView', $actionView).show()
if (spiHelperIsAdmin()) {
$('#spiHelper_blockTagHeader', $actionView).text('Blocking and tagging socks')
} else {
$('#spiHelper_blockTagHeader', $actionView).text('Tagging socks')
}
// Wire up the "select all" options
$('#spiHelper_block_doblock', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_acb', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_ab', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_tp', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_email', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_lock', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_lock', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
spiHelperGenerateSelect('spiHelper_block_tag', spiHelperTagOptions)
$('#spiHelper_block_tag', $actionView).on('change', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
spiHelperGenerateSelect('spiHelper_block_tag_altmaster', spiHelperAltMasterTagOptions)
$('#spiHelper_block_tag_altmaster', $actionView).on('change', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
$('#spiHelper_block_lock', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'block')
})
for (let i = 0; i < likelyusers.length; i++) {
spiHelperBlockTableUserCount++
await spiHelperGenerateBlockTableLine(likelyusers[i], true, spiHelperBlockTableUserCount)
}
for (let i = 0; i < likelyips.length; i++) {
spiHelperBlockTableUserCount++
await spiHelperGenerateBlockTableLine(likelyips[i], true, spiHelperBlockTableUserCount)
}
for (let i = 0; i < possibleusers.length; i++) {
spiHelperBlockTableUserCount++
await spiHelperGenerateBlockTableLine(possibleusers[i], false, spiHelperBlockTableUserCount)
}
for (let i = 0; i < possibleips.length; i++) {
spiHelperBlockTableUserCount++
await spiHelperGenerateBlockTableLine(possibleips[i], false, spiHelperBlockTableUserCount)
}
}
if (spiHelperActionsSelected.Link) {
// Wire up the "select all" options
$('#spiHelper_link_editorInteractionAnalyser', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'link')
})
$('#spiHelper_link_interactionTimeline', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'link')
})
$('#spiHelper_link_timecardSPITools', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'link')
})
$('#spiHelper_link_consolidatedTimelineSPITools', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'link')
})
$('#spiHelper_link_pagesSPITools', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'link')
})
$('#spiHelper_link_checkUserWikiSearch', $actionView).on('click', function (e) {
spiHelperSetAllTableColumnOpts($(e.target), 'link')
})
for (let i = 0; i < likelyusers.length; i++) {
spiHelperLinkTableUserCount++
await spiHelperGenerateLinksTableLine(likelyusers[i], spiHelperLinkTableUserCount)
}
for (let i = 0; i < likelyips.length; i++) {
spiHelperLinkTableUserCount++
await spiHelperGenerateLinksTableLine(likelyips[i], spiHelperLinkTableUserCount)
}
for (let i = 0; i < possibleusers.length; i++) {
spiHelperLinkTableUserCount++
await spiHelperGenerateLinksTableLine(possibleusers[i], spiHelperLinkTableUserCount)
}
for (let i = 0; i < possibleips.length; i++) {
spiHelperLinkTableUserCount++
await spiHelperGenerateLinksTableLine(possibleips[i], spiHelperLinkTableUserCount)
}
$('#spiHelper_sockLinksView', $actionView).show()
}
$('#spiHelper_blockTagView', $actionView).show()
}
// Wire up the submit button
$('#spiHelper_performActions', $actionView).one('click', () => {
spiHelperPerformActions()
})
}
/**
* Update the view for the roles of the person running the script
* by selectively hiding.
* view: @type JQuery object representing the class / id for the view
*/
async function updateForRole (view) {
// Hide items based on role
if (!spiHelperIsCheckuser()) {
// Hide CU options from non-CUs
$('.spiHelper_cuClass', view).hide()
}
if (!spiHelperIsAdmin()) {
// Hide block options from non-admins
$('.spiHelper_adminClass', view).hide()
}
if (!(spiHelperIsAdmin() || spiHelperIsClerk())) {
$('.spiHelper_adminClerkClass', view).hide()
}
}
/**
* Archives everything on the page that's eligible for archiving
*/
async function spiHelperOneClickArchive () {
'use strict'
spiHelperActiveOperations.set('oneClickArchive', 'running')
const pagetext = await spiHelperGetPageText(spiHelperPageName, false)
spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs()
if (!spiHelperSectionRegex.test(pagetext)) {
alert('Looks like the page has been archived already.')
spiHelperActiveOperations.set('oneClickArchive', 'successful')
return
}
displayMessage('<ul id="spiHelper_status"/>')
await spiHelperArchiveCase()
await spiHelperPurgePage(spiHelperPageName)
const logMessage = '* [[' + spiHelperPageName + ']]: used one-click archiver ~~~~~'
if (spiHelperSettings.log) {
spiHelperLog(logMessage)
}
$('#spiHelper_status', document).append($('<li>').text('Done!'))
spiHelperActiveOperations.set('oneClickArchive', 'successful')
}
/**
* Given a tag entry, runs the required logic and tags the user
* @param {TagEntry} tagEntry Tag entry to run the logic for
* @param {boolean} tagNonLocalAccounts Whether to tag accounts that don't exist locally
* @param {string} sockmaster The username of the sockmaster to tag for
* @param {string} altmaster The username of the alternate master to tag for
* @return {Promise<boolean>} Whether the tag was successfully applied
*/
async function spiHelperTagUser (tagEntry, tagNonLocalAccounts, sockmaster, altmaster) {
if (mw.util.isIPAddress(tagEntry.username, true)) {
return false // do not support tagging IPs
}
const existsGlobally = await spiHelperDoesUserExistGlobally(tagEntry.username)
const existsLocally = await spiHelperDoesUserExistLocally(tagEntry.username)
if (!existsGlobally && !existsLocally) {
// Skip, don't tag accounts that don't exist
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
$statusLine.addClass('spihelper-errortext').html('<b>The account ' + tagEntry.username + ' does not exist and so has not been tagged.</b>')
return false
}
if (!tagNonLocalAccounts && existsGlobally && !existsLocally) {
// Skip as the account does not exist locally and the "tag accounts that exist locally" setting is unchecked.
return false
}
let tagText = ''
let altmasterName = ''
let altmasterTag = ''
if (altmaster !== '' && tagEntry.altmasterTag !== '') {
altmasterName = altmaster
altmasterTag = tagEntry.altmasterTag
}
let isMaster = false
let tag = ''
let checked = ''
switch (tagEntry.tag) {
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
default:
tag = tagEntry.tag
}
const isLocked = await spiHelperIsUserGloballyLocked(tagEntry.username) ? 'yes' : 'no'
const isNotBlocked = !existsLocally || !(await spiHelperGetUserBlockReason(tagEntry.username))
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 === 'suspected' ? 'blocked' : altmasterTag
altmasterName = ''
altmasterTag = ''
tagText += '\n'
}
tagText += `{{sockpuppet
| 1 = ${sockmasterName}
| 2 = ${tag}
| locked = ${isLocked}
| notblocked = ${isNotBlocked ? 'yes' : 'no'}
| altmaster = ${altmasterName}
| altmaster-status = ${altmasterTag}
}}`
}
spiHelperEditPage('User:' + tagEntry.username, tagText, 'Adding sockpuppetry tag per [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]',
false, spiHelperSettings.watchTaggedUser, spiHelperSettings.watchTaggedUserExpiry)
return true
}
/**
* Given a block entry, runs the required logic and blocks the user
*
* @param {BlockEntry} blockEntry Block entry to run the logic for
* @param {boolean} cuBlock Whether to use the {{checkuserblock}} template family
* @param {boolean} cuBlockOnly Whether to use just {{checkuserblock}} without an additional summary
* @param {boolean} overrideExisting Whether any existing blocks should be overriden
* @param {boolean} blankTalk Whether the user's talk page should be blanked before adding the block template
* @param {string} sockmaster Username of the sockmaster
* @return {Promise<boolean>} Whether the block succeeded
*/
async function spiHelperBlockUser (blockEntry, cuBlock, cuBlockOnly, overrideExisting, blankTalk, sockmaster) {
const blockReason = await spiHelperGetUserBlockReason(blockEntry.username)
if (!spiHelperIsCheckuser() && overrideExisting &&
spiHelperCUBlockRegex.exec(blockReason)) {
// If you're not a checkuser, we've asked to overwrite existing blocks, and the block
// target has a CU block on them, check whether that was intended
if (!confirm('User ' + blockEntry.username + ' appears to be CheckUser-blocked, are you SURE you want to re-block them?\n' +
'Current block message:\n' + blockReason
)) {
return false
}
}
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: [[' + spiHelperInterwikiPrefix + spiHelperPageName + ']]'
if (spiHelperIsCheckuser() && cuBlock) {
const cublockTemplate = isIP ? ('{{checkuserblock}}') : ('{{checkuserblock-account}}')
if (cuBlockOnly) {
blockSummary = cublockTemplate
} else {
blockSummary = cublockTemplate + ': ' + blockSummary
}
} else if (isIPRange) {
blockSummary = '{{rangeblock|1= ' + blockSummary +
(blockEntry.acb ? '' : '|create=yes') + '}}'
}
const blockSuccess = await spiHelperWikiBlockUser(
blockEntry.username,
blockEntry.duration,
blockSummary,
overrideExisting,
(isIP ? blockEntry.ab : false),
blockEntry.acb,
(isIP ? false : blockEntry.ab),
blockEntry.ntp,
blockEntry.nem,
spiHelperSettings.watchBlockedUser,
spiHelperSettings.watchBlockedUserExpiry)
if (!blockSuccess) {
// Don't add a block notice if we failed to block
if (blockEntry.tpn) {
// Also warn the user if we were going to post a block notice on their talk page
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
$statusLine.addClass('spihelper-errortext').html('<b>Block failed on ' + blockEntry.username + ', not adding talk page notice</b>')
}
return false
}
if (isIPRange) {
// There isn't really a talk page for an IP range, so return here before we reach that section
return blockSuccess
}
// Talk page notice
if (blockEntry.tpn) {
let newText = ''
let isSock = blockEntry.tpn.includes('sock')
// Hacky workaround for when we didn't make a master tag
if (isSock && blockEntry.username === spiHelperNormalizeUsername(sockmaster)) {
isSock = false
}
if (isSock) {
newText = '== Blocked as a sockpuppet ==\n'
} else {
newText = '== Blocked for sockpuppetry ==\n'
}
const isCheckUserBlockAccount = spiHelperIsCheckuser() && cuBlock && spiHelperSettings.useCheckuserblockAccount
if (isCheckUserBlockAccount) {
newText += '{{checkuserblock-account|sig=~~~~'
} else {
newText += '{{subst:uw-sockblock|sig=yes'
}
newText += '|spi=' + spiHelperCaseName
if (blockEntry.duration === 'indefinite' || blockEntry.duration === 'infinity') {
newText += '|indef=yes'
} else {
newText += '|time=' + blockEntry.duration
if (isCheckUserBlockAccount) {
newText += '|indef=no'
}
}
if (blockEntry.ntp) {
newText += '|notalk=yes'
}
if (isSock) {
newText += '|master=' + sockmaster
}
newText += '}}'
if (!blankTalk) {
const oldtext = await spiHelperGetPageText('User talk:' + blockEntry.username, true)
if (oldtext !== '') {
newText = oldtext + '\n' + newText
}
}
// Hardcode the watch setting to 'nochange' since we will have either watched or not watched based on the _boolean_
// watchBlockedUser
spiHelperEditPage('User talk:' + blockEntry.username,
newText, 'Adding sockpuppetry block notice per [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]', false, 'nochange')
}
return true
}
/**
* Goes through the action selections and executes them meaty
*/
async function spiHelperPerformActions () {
'use strict'
spiHelperActiveOperations.set('mainActions', 'running')
// Again, reduce the search scope
const $actionView = $('#spiHelper_actionViewDiv', document)
// set up a few function-scoped vars
let comment = ''
let cuBlock = false
let cuBlockOnly = false
let newCaseStatus = 'noaction'
let renameTarget = ''
/** @type {boolean} */
const blankTalk = $('#spiHelper_blanktalk', $actionView).prop('checked')
/** @type {boolean} */
const overrideExisting = $('#spiHelper_override', $actionView).prop('checked')
/** @type {boolean} */
const hideLockNames = $('#spiHelper_hidelocknames', $actionView).prop('checked')
if (spiHelperActionsSelected.Case_act) {
newCaseStatus = $('#spiHelper_CaseAction', $actionView).val().toString()
}
if (spiHelperActionsSelected.SpiMgmt) {
spiHelperArchiveNoticeParams.deny = $('#spiHelper_spiMgmt_deny', $actionView).prop('checked')
spiHelperArchiveNoticeParams.xwiki = $('#spiHelper_spiMgmt_crosswiki', $actionView).prop('checked')
spiHelperArchiveNoticeParams.notalk = $('#spiHelper_spiMgmt_notalk', $actionView).prop('checked')
}
if (spiHelperSectionId && !spiHelperIsThisPageAnArchive) {
comment = $('#spiHelper_CommentText', $actionView).val().toString()
}
if (spiHelperActionsSelected.Block) {
if (spiHelperIsCheckuser()) {
cuBlock = $('#spiHelper_cublock', $actionView).prop('checked')
cuBlockOnly = $('#spiHelper_cublockonly', $actionView).prop('checked')
}
if (spiHelperIsAdmin() && !$('#spiHelper_noblock', $actionView).prop('checked')) {
const masterNotice = $('#spiHelper_blocknoticemaster', $actionView).prop('checked')
const sockNotice = $('#spiHelper_blocknoticesocks', $actionView).prop('checked')
for (let i = 1; i <= spiHelperBlockTableUserCount; i++) {
if ($('#spiHelper_block_doblock' + i, $actionView).prop('checked')) {
const usernameValue = $('#spiHelper_block_username' + i, $actionView).val()
if (!usernameValue) {
// Skip blank usernames, empty string is falsey
continue
}
let noticetype = ''
const username = spiHelperNormalizeUsername(usernameValue.toString())
if (masterNotice && ($('#spiHelper_block_tag' + i, $actionView).val().toString().includes('master') ||
spiHelperNormalizeUsername(spiHelperCaseName) === username)) {
noticetype = 'master'
} else if (sockNotice) {
noticetype = 'sock'
}
/** @type {BlockEntry} */
const item = {
username: username,
duration: $('#spiHelper_block_duration' + i, $actionView).val().toString(),
acb: $('#spiHelper_block_acb' + i, $actionView).prop('checked'),
ab: $('#spiHelper_block_ab' + i, $actionView).prop('checked'),
ntp: $('#spiHelper_block_tp' + i, $actionView).prop('checked'),
nem: $('#spiHelper_block_email' + i, $actionView).prop('checked'),
tpn: noticetype
}
spiHelperBlocks.push(item)
}
if ($('#spiHelper_block_lock' + i, $actionView).prop('checked')) {
spiHelperGlobalLocks.push($('#spiHelper_block_username' + i, $actionView).val().toString())
}
if ($('#spiHelper_block_tag' + i).val() !== '') {
if (!$('#spiHelper_block_username' + i, $actionView).val().toString()) {
// Skip blank entries
continue
}
const item = {
username: spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val().toString()),
tag: $('#spiHelper_block_tag' + i, $actionView).val().toString(),
altmasterTag: $('#spiHelper_block_tag_altmaster' + i, $actionView).val().toString(),
blocking: $('#spiHelper_block_doblock' + i, $actionView).prop('checked')
}
spiHelperTags.push(item)
}
}
} else {
for (let i = 1; i <= spiHelperBlockTableUserCount; i++) {
if (!$('#spiHelper_block_username' + i, $actionView).val().toString()) {
// Skip blank entries
continue
}
if ($('#spiHelper_block_tag' + i, $actionView).val() !== '') {
const item = {
username: spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val().toString()),
tag: $('#spiHelper_block_tag' + i, $actionView).val().toString(),
altmasterTag: $('#spiHelper_block_tag_altmaster' + i, $actionView).val().toString(),
blocking: false
}
spiHelperTags.push(item)
}
if ($('#spiHelper_block_lock' + i, $actionView).prop('checked')) {
spiHelperGlobalLocks.push(spiHelperNormalizeUsername($('#spiHelper_block_username' + i, $actionView).val().toString()))
}
}
}
}
if (spiHelperActionsSelected.Close) {
spiHelperActionsSelected.Close = $('#spiHelper_CloseCase', $actionView).prop('checked')
}
if (spiHelperActionsSelected.Rename) {
renameTarget = spiHelperNormalizeUsername($('#spiHelper_moveTarget', $actionView).val().toString())
}
if (spiHelperActionsSelected.Archive) {
spiHelperActionsSelected.Archive = $('#spiHelper_ArchiveCase', $actionView).prop('checked')
}
displayMessage('<div id="linkViewResults" hidden><h4>Generated links</h4><ul id="linkViewResultsList"></ul></div><h4>Running actions</h4><ul id="spiHelper_status" />')
const $statusAnchor = $('#spiHelper_status', document)
let sectionText = await spiHelperGetPageText(spiHelperPageName, true, spiHelperSectionId)
let editsummary = ''
let logMessage = '* [[' + spiHelperPageName + ']]'
if (spiHelperSectionId) {
logMessage += ' (section ' + spiHelperSectionName + ')'
} else {
logMessage += ' (full case)'
}
logMessage += ' ~~~~~'
if (spiHelperActionsSelected.Link) {
$('#linkViewResults', document).show()
const spiHelperUsersForLinks = {
editorInteractionAnalyser: [],
interactionTimeline: [],
timecardSPITools: [],
consolidatedTimelineSPITools: [],
pagesSPITools: [],
checkUserWikiSearch: []
}
for (let i = 1; i <= spiHelperLinkTableUserCount; i++) {
const username = $('#spiHelper_link_username' + i, $actionView).val().toString()
if (!username) {
// Skip blank usernames
continue
}
if ($('#spiHelper_link_editorInteractionAnalyser' + i, $actionView).prop('checked')) spiHelperUsersForLinks.editorInteractionAnalyser.push(username)
if ($('#spiHelper_link_interactionTimeline' + i, $actionView).prop('checked')) spiHelperUsersForLinks.interactionTimeline.push(username)
if ($('#spiHelper_link_timecardSPITools' + i, $actionView).prop('checked')) spiHelperUsersForLinks.timecardSPITools.push(username)
if ($('#spiHelper_link_consolidatedTimelineSPITools' + i, $actionView).prop('checked')) spiHelperUsersForLinks.consolidatedTimelineSPITools.push(username)
if ($('#spiHelper_link_pagesSPITools' + i, $actionView).prop('checked')) spiHelperUsersForLinks.pagesSPITools.push(username)
if ($('#spiHelper_link_checkUserWikiSearch' + i, $actionView).prop('checked')) spiHelperUsersForLinks.checkUserWikiSearch.push(username)
}
const $linkViewList = $('#linkViewResultsList', document)
for (const link in spiHelperUsersForLinks) {
if (spiHelperUsersForLinks[link].length === 0) continue
const URLentry = spiHelperLinkViewURLFormats[link]
let generatedURL = URLentry.baseurl + '?' + (URLentry.multipleUserQueryStringKeys ? '' : URLentry.userQueryStringKey + '=')
for (let i = 0; i < spiHelperUsersForLinks[link].length; i++) {
const username = spiHelperUsersForLinks[link][i]
generatedURL += (i === 0 ? '' : URLentry.userQueryStringSeparator)
if (URLentry.multipleUserQueryStringKeys) {
generatedURL += URLentry.userQueryStringKey + '=' + URLentry.userQueryStringWrapper + encodeURIComponent(username) + URLentry.userQueryStringWrapper
} else {
generatedURL += URLentry.userQueryStringWrapper + encodeURIComponent(username) + URLentry.userQueryStringWrapper
}
}
generatedURL += (URLentry.appendToQueryString === '' ? '' : '&') + URLentry.appendToQueryString
const $statusLine = $('<li>').appendTo($linkViewList)
const $statusLineLink = $('<a>').appendTo($statusLine)
$statusLineLink.attr('href', generatedURL).attr('target', '_blank').attr('rel', 'noopener noreferrer').text(spiHelperLinkViewURLFormats[link].name)
}
}
if (spiHelperSectionId !== null && !spiHelperIsThisPageAnArchive) {
let caseStatusResult = spiHelperCaseStatusRegex.exec(sectionText)
if (caseStatusResult === null) {
sectionText = sectionText.replace(/^(\s*===.*===[^\S\r\n]*)/, '$1\n{{SPI case status|}}')
caseStatusResult = spiHelperCaseStatusRegex.exec(sectionText)
}
const oldCaseStatus = caseStatusResult[1] || 'open'
if (newCaseStatus === 'noaction') {
newCaseStatus = oldCaseStatus
}
if (spiHelperActionsSelected.Case_act && newCaseStatus !== 'noaction' && newCaseStatus !== oldCaseStatus) {
switch (newCaseStatus) {
case 'reopen':
newCaseStatus = 'open'
editsummary = 'Reopening'
break
case 'open':
editsummary = 'Marking request as open'
break
case 'CUrequest':
editsummary = 'Adding checkuser request'
break
case 'admin':
editsummary = 'Requesting admin action'
break
case 'clerk':
editsummary = 'Requesting clerk action'
break
case 'selfendorse':
newCaseStatus = 'endorse'
editsummary = 'Adding checkuser request (self-endorsed for checkuser attention)'
break
case 'checked':
editsummary = 'Marking request as checked'
break
case 'inprogress':
editsummary = 'Marking request in progress'
break
case 'decline':
editsummary = 'Declining checkuser'
break
case 'cudecline':
editsummary = 'CU declining checkuser'
break
case 'endorse':
editsummary = 'Endorsing for checkuser attention'
break
case 'cuendorse':
editsummary = 'CU endorsing for checkuser attention'
break
case 'moreinfo': // Intentional fallthrough
case 'cumoreinfo':
editsummary = 'Requesting additional information'
break
case 'relist':
editsummary = 'Relisting case for another check'
break
case 'hold':
editsummary = 'Putting case on hold'
break
case 'cuhold':
editsummary = 'Placing checkuser request on hold'
break
case 'noaction':
// Do nothing
break
default:
console.error('Unexpected case status value ' + newCaseStatus)
}
logMessage += '\n** changed case status from ' + oldCaseStatus + ' to ' + newCaseStatus
}
}
if (spiHelperActionsSelected.SpiMgmt) {
const newArchiveNotice = spiHelperMakeNewArchiveNotice(spiHelperCaseName, spiHelperArchiveNoticeParams)
sectionText = sectionText.replace(spiHelperArchiveNoticeRegex, newArchiveNotice)
if (editsummary) {
editsummary += ', update archivenotice'
} else {
editsummary = 'Update archivenotice'
}
logMessage += '\n** Updated archivenotice'
}
let loggingPromise = Promise.all([Promise.resolve()])
// Possibly build these inside the promises themselves?
const loggingArrays = {
blocked: [], tagged: []
}
if (spiHelperActionsSelected.Block) {
let sockmaster = ''
let altmaster = ''
let needsAltmaster = false
spiHelperTags.forEach(async (tagEntry) => {
// we do not support tagging IPs
if (mw.util.isIPAddress(tagEntry.username, true)) {
// Skip, this is an IP
return
}
if (tagEntry.tag.includes('master')) {
sockmaster = tagEntry.username
}
if (tagEntry.altmasterTag !== '') {
needsAltmaster = true
}
})
if (sockmaster === '') {
sockmaster = prompt('Please enter the name of the sockmaster: ', spiHelperCaseName) || spiHelperCaseName
}
if (needsAltmaster) {
altmaster = prompt('Please enter the name of the alternate sockmaster: ', spiHelperCaseName) || spiHelperCaseName
}
const tagNonLocalAccounts = $('#spiHelper_tagAccountsWithoutLocalAccount', $actionView).prop('checked')
let blockingPromises
if (spiHelperIsAdmin()) {
// Block, then tag
blockingPromises = Promise.all(spiHelperBlocks.map(async (blockEntry) => {
await spiHelperBlockUser(blockEntry, cuBlock, cuBlockOnly, overrideExisting, blankTalk, sockmaster).then(async (success) => {
if (success) {
loggingArrays.blocked.push('{{noping|' + blockEntry.username + '}}')
}
const tagEntry = spiHelperTags.find((tag) => tag.username === blockEntry.username)
if (typeof tagEntry !== 'undefined') {
await spiHelperTagUser(tagEntry, tagNonLocalAccounts, sockmaster, altmaster).then((success) => {
if (success) {
loggingArrays.tagged.push('{{noping|' + tagEntry.username + '}}')
}
})
}
})
}))
}
const taggingPromises = Promise.all(spiHelperTags.map(async (tagEntry) => {
if (tagEntry.blocking) {
return
}
await spiHelperTagUser(tagEntry, tagNonLocalAccounts, sockmaster, altmaster).then((success) => {
if (success) {
loggingArrays.tagged.push('{{noping|' + tagEntry.username + '}}')
}
})
}))
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
const checkConfirmedCat = spiHelperTags.some((tagEntry) => tagEntry.tag === 'proven')
const checkSuspectedCat = spiHelperTags.some((tagEntry) => tagEntry.tag === 'blocked')
const checkAltSuspectedCat = altmaster !== '' ? spiHelperTags.some((tagEntry) => tagEntry.altmasterTag !== '' && tagEntry.altmasterTag === 'suspected') : false
const checkAltConfirmedCat = altmaster !== '' ? spiHelperTags.some((tagEntry) => tagEntry.altmasterTag !== '' && tagEntry.altmasterTag === 'proven') : false
if (checkAltConfirmedCat) {
const catname = 'Category:Wikipedia sockpuppets of ' + altmaster
const cattext = await spiHelperGetPageText(catname, false)
// Empty text means the page doesn't exist - create it
if (!cattext) {
await spiHelperEditPage(catname, '{{sockpuppet category}}',
'Creating sockpuppet category per [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]',
true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry)
needsPurge = true
}
}
if (checkAltSuspectedCat) {
const catname = 'Category:Suspected Wikipedia sockpuppets of ' + altmaster
const cattext = await spiHelperGetPageText(catname, false)
if (!cattext) {
await spiHelperEditPage(catname, '{{sockpuppet category}}',
'Creating sockpuppet category per [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]',
true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry)
needsPurge = true
}
}
if (checkConfirmedCat) {
const catname = 'Category:Wikipedia sockpuppets of ' + sockmaster
const cattext = await spiHelperGetPageText(catname, false)
if (!cattext) {
await spiHelperEditPage(catname, '{{sockpuppet category}}',
'Creating sockpuppet category per [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]',
true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry)
needsPurge = true
}
}
if (checkSuspectedCat) {
const catname = 'Category:Suspected Wikipedia sockpuppets of ' + sockmaster
const cattext = await spiHelperGetPageText(catname, false)
if (!cattext) {
await spiHelperEditPage(catname, '{{sockpuppet category}}',
'Creating sockpuppet category per [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]',
true, spiHelperSettings.watchNewCats, spiHelperSettings.watchNewCatsExpiry)
needsPurge = true
}
}
// Purge the sock pages if we created a category (to get rid of
// the issue where the page says "click here to create category"
// when the category was created after the page)
if (needsPurge) {
spiHelperTags.forEach((tagEntry) => {
if (mw.util.isIPAddress(tagEntry.username, true)) {
// Skip, this is an IP
return
}
if (!tagEntry.tag && !tagEntry.altmasterTag) {
// Skip, not tagged
return
}
// Not bothering with an await, no need for async behavior here
spiHelperPurgePage('User:' + tagEntry.username)
})
}
}
if (spiHelperGlobalLocks.length > 0) {
let locked = ''
let templateContent = ''
let matchCount = 0
spiHelperGlobalLocks.forEach(async (globalLockEntry) => {
// do not support locking IPs (those are global blocks, not
// locks, and are handled a bit differently)
if (mw.util.isIPAddress(globalLockEntry, true)) {
return
}
templateContent += '|' + (matchCount + 1) + '=' + globalLockEntry
if (locked) {
locked += ', '
}
locked += '{{noping|1=' + 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: ', spiHelperCaseName) || spiHelperCaseName
}
const usePlural = matchCount > 1
const lockComment = prompt('Please enter a comment for the global lock request (optional):', '') || ''
const heading = hideLockNames ? (usePlural ? 'sockpuppets' : 'sockpuppet') : '[[Special:CentralAuth/' + sockmaster + '|' + sockmaster + ']] ' + (usePlural ? 'socks' : 'sock')
let message = '=== Global lock for ' + heading + ' ==='
message += '\n{{status}}'
message += '\n' + lockTemplate
message += '\n' + (usePlural ? 'Sockpuppets' : 'Sockpuppet') + ' found in enwiki sockpuppet investigation, see [[' + spiHelperInterwikiPrefix + spiHelperPageName + ']]. ' + lockComment + ' ~~~~'
// Write lock request to [[meta:Steward requests/Global]]
let srgText = await spiHelperGetPageText('meta:Steward requests/Global', false)
srgText = srgText.replace(/\n+(== See also == *\n)/, '\n\n' + message + '\n\n$1')
spiHelperEditPage('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
}
}
loggingPromise = Promise.all([blockingPromises, taggingPromises])
}
if (spiHelperSectionId && comment && comment !== '*' && !spiHelperIsThisPageAnArchive) {
if (!sectionText.includes('\n----')) {
sectionText.replace('<!--- All comments go ABOVE this line, please. -->', '')
sectionText.replace('<!-- All comments go ABOVE this line, please. -->', '')
sectionText += '\n----<!-- All comments go ABOVE this line, please. -->'
}
if (!/~~~~/.test(comment)) {
comment += ' ~~~~'
}
// Clerks and admins post in the admin section
if (spiHelperIsClerk() || spiHelperIsAdmin()) {
// Complicated regex to find the first regex in the admin section
// The weird (\n|.) is because we can't use /s (dot matches newline) regex mode without ES9,
// I don't want to go there yet
sectionText = sectionText.replace(/\n*----(?!(\n|.)*----)/, '\n' + comment + '\n----')
} else { // Everyone else posts in the "other users" section
sectionText = sectionText.replace(spiHelperAdminSectionWithPrecedingNewlinesRegex,
'\n' + comment + '\n====<big>Clerk, CheckUser, and/or patrolling admin comments</big>====\n')
}
if (editsummary) {
editsummary += ', comment'
} else {
editsummary = 'Comment'
}
logMessage += '\n** commented'
}
if (spiHelperActionsSelected.Close) {
newCaseStatus = 'close'
if (editsummary) {
editsummary += ', marking case as closed'
} else {
editsummary = 'Marking case as closed'
}
logMessage += '\n** closed case'
}
if (spiHelperSectionId !== null && !spiHelperIsThisPageAnArchive) {
const caseStatusText = spiHelperCaseStatusRegex.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), unless the page is an archive (as there should be no edits made)
if (!spiHelperIsThisPageAnArchive) {
const editResult = await spiHelperEditPage(spiHelperPageName, sectionText, editsummary, false,
spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID, spiHelperSectionId)
if (!editResult) {
// Page edit failed (probably an edit conflict), dump the comment if we had one
if (comment && comment !== '*') {
$('<li>')
.append($('<div>').addClass('spihelper-errortext')
.append($('<b>').text('SPI page edit failed! Comment was: ' + comment)))
.appendTo($('#spiHelper_status', document))
}
}
}
// Update to the latest revision ID
spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName)
if (spiHelperActionsSelected.Archive) {
// Archive the case
if (spiHelperSectionId === null) {
// Archive the whole case
logMessage += '\n** Archived case'
await spiHelperArchiveCase()
} else {
// Just archive the selected section
logMessage += '\n** Archived section'
await spiHelperArchiveCaseSection(spiHelperSectionId)
}
} else if (spiHelperActionsSelected.Rename && renameTarget) {
if (spiHelperSectionId === null) {
// Option 1: we selected "All cases," this is a whole-case move/merge
logMessage += '\n** moved/merged case to ' + renameTarget
await spiHelperMoveCase(renameTarget)
} else {
// Option 2: this is a single-section case move or merge
logMessage += '\n** moved section to ' + renameTarget
await spiHelperMoveCaseSection(renameTarget, spiHelperSectionId)
}
}
if (spiHelperSettings.log) {
loggingPromise.then(async () => {
if (loggingArrays.blocked.length > 0) {
logMessage += '\n** blocked ' + loggingArrays.blocked.join(', ')
}
if (loggingArrays.tagged.length > 0) {
logMessage += '\n** tagged ' + loggingArrays.tagged.join(', ')
}
await spiHelperLog(logMessage)
})
}
await spiHelperPurgePage(spiHelperPageName)
$('#spiHelper_status', document).append($('<li>').text('Done!'))
spiHelperActiveOperations.set('mainActions', 'successful')
}
/**
* Logs SPI actions to userspace a la Twinkle's CSD/prod/etc. logs
*
* @param {string} logString String with the changes the user made
*/
async function spiHelperLog (logString) {
const now = new Date()
const dateString = now.toLocaleString('en', { month: 'long' }) + ' ' +
now.toLocaleString('en', { year: 'numeric' })
const dateHeader = '==\\s*' + dateString + '\\s*=='
const dateHeaderRe = new RegExp(dateHeader, 'i')
const dateHeaderReWithAnyDate = /==.*?==/i
let logPageText = await spiHelperGetPageText('User:' + mw.config.get('wgUserName') + '/spihelper_log', false)
if (!logPageText.match(dateHeaderRe)) {
if (spiHelperSettings.reversed_log) {
const firstHeaderMatch = logPageText.match(dateHeaderReWithAnyDate)
logPageText = logPageText.substring(0, firstHeaderMatch.index) + '== ' + dateString + ' ==\n' + logPageText.substring(firstHeaderMatch.index)
} else {
logPageText += '\n== ' + dateString + ' =='
}
}
if (spiHelperSettings.reversed_log) {
const firstHeaderMatch = logPageText.match(dateHeaderReWithAnyDate)
logPageText = logPageText.substring(0, firstHeaderMatch.index + firstHeaderMatch[0].length) + '\n' + logString + logPageText.substring(firstHeaderMatch.index + firstHeaderMatch[0].length)
} else {
logPageText += '\n' + logString
}
await spiHelperEditPage('User:' + mw.config.get('wgUserName') + '/spihelper_log', logPageText, 'Logging spihelper edits', false, 'nochange')
}
// Major helper functions
/**
* Cleanups following a rename - update the archive notice, add an archive notice to the
* old case name, add the original sockmaster to the sock list for reference
*
* @param {string} oldCasePage Title of the previous case page
*/
async function spiHelperPostRenameCleanup (oldCasePage) {
'use strict'
const replacementArchiveNotice = spiHelperMakeNewArchiveNotice(spiHelperCaseName, spiHelperArchiveNoticeParams)
const oldCaseName = oldCasePage.replace(/Wikipedia:Sockpuppet investigations\//g, '')
// Update previous SPI redirects to this location
const pagesChecked = []
const pagesToCheck = [oldCasePage]
let currentPageToCheck = null
while (pagesToCheck.length !== 0) {
currentPageToCheck = pagesToCheck.pop()
if (currentPageToCheck === spiHelperPageName || currentPageToCheck === oldCasePage) {
continue
}
pagesChecked.push(currentPageToCheck)
const backlinks = await spiHelperGetSPIBacklinks(currentPageToCheck)
for (let i = 0; i < backlinks.length; i++) {
if ((await spiHelperParseArchiveNotice(backlinks[i].title)).username === currentPageToCheck.replace(/Wikipedia:Sockpuppet investigations\//g, '')) {
spiHelperEditPage(backlinks[i].title, replacementArchiveNotice, 'Updating case following page move', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)
if (pagesChecked.indexOf(backlinks[i].title) !== -1) {
pagesToCheck.push(backlinks[i])
}
}
}
}
// The old case should just be the archivenotice template and point to the new case
spiHelperEditPage(oldCasePage, replacementArchiveNotice, 'Updating case following page move', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)
// The new case's archivenotice should be updated with the new name
let newPageText = await spiHelperGetPageText(spiHelperPageName, true)
newPageText = newPageText.replace(spiHelperArchiveNoticeRegex, '{{SPIarchive notice|1=' + spiHelperCaseName + '$2}}')
// We also want to add the previous master to the sock list
// We use SOCK_SECTION_RE_WITH_NEWLINE to clean up any extraneous whitespace
newPageText = newPageText.replace(spiHelperSockSectionWithNewlineRegex, '====Suspected sockpuppets====' +
'\n* {{checkuser|1=' + 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=)?' + spiHelperCaseName + '(?:\\|master name\\s*=.*?)?}}\\s*$(.*====\\s*<big>)'
const newMasterRe = new RegExp(newMasterReString, 'sm')
newPageText = newPageText.replace(newMasterRe, '$1\n$2')
await spiHelperEditPage(spiHelperPageName, newPageText, 'Updating case following page move', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)
// Update to the latest revision ID
spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName)
}
/**
* Cleanups following a merge - re-insert the original page text
*
* @param {string} originalText Text of the page pre-merge
*/
async function spiHelperPostMergeCleanup (originalText) {
'use strict'
let newText = await spiHelperGetPageText(spiHelperPageName, false)
// Remove the SPI header templates from the page
newText = newText.replace(/\n*<noinclude>__TOC__.*\n/ig, '')
newText = newText.replace(spiHelperArchiveNoticeRegex, '')
newText = newText.replace(spiHelperPriorCasesRegex, '')
newText = originalText + '\n' + newText
// Write the updated case
await spiHelperEditPage(spiHelperPageName, newText, 'Re-adding previous cases following merge', false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)
// Update to the latest revision ID
spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName)
}
/**
* Archive all closed sections of a case
*/
async function spiHelperArchiveCase () {
'use strict'
let i = 0
let previousRev = 0
while (i < spiHelperCaseSections.length) {
const sectionId = spiHelperCaseSections[i].index
const sectionText = await spiHelperGetPageText(spiHelperPageName, false,
sectionId)
const currentRev = await spiHelperGetPageRev(spiHelperPageName)
if (previousRev === currentRev && currentRev !== 0) {
// Our previous archive hasn't gone through yet, wait a bit and retry
await new Promise((resolve) => {
setTimeout(resolve, 100)
})
// Re-grab the case sections list since the page may have updated
spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs()
continue
}
previousRev = await spiHelperGetPageRev(spiHelperPageName)
i++
const result = spiHelperCaseStatusRegex.exec(sectionText)
if (result === null) {
// Bail out - can't find the case status template in this section
continue
}
if (spiHelperCaseClosedRegex.test(result[1])) {
// A running concern with the SPI archives is whether they exceed the post-expand
// include size. Calculate what percent of that size the archive will be if we
// add the current page to it - if >1, we need to archive the archive
const postExpandPercent =
(await spiHelperGetPostExpandSize(spiHelperPageName, sectionId) +
await spiHelperGetPostExpandSize(spiHelperGetArchiveName())) /
spiHelperGetMaxPostExpandSize()
if (postExpandPercent >= 1) {
// We'd overflow the archive, so move it and then archive the current page
// Find the first empty archive page
let archiveId = 1
while (await spiHelperGetPageText(spiHelperGetArchiveName() + '/' + archiveId, false) !== '') {
archiveId++
}
const newArchiveName = spiHelperGetArchiveName() + '/' + archiveId
await spiHelperMovePage(spiHelperGetArchiveName(), newArchiveName, 'Moving archive to avoid exceeding post expand size limit', false, false)
await spiHelperEditPage(spiHelperGetArchiveName(), '', 'Removing redirect', false, 'nochange')
}
// Need an await here - if we have multiple sections archiving we don't want
// to stomp on each other
await spiHelperArchiveCaseSection(sectionId)
// need to re-fetch caseSections since the section numbering probably just changed,
// also reset our index
i = 0
spiHelperCaseSections = await spiHelperGetInvestigationSectionIDs()
}
}
}
/**
* Archive a specific section of a case
*
* @param {!number} sectionId The section number to archive
*/
async function spiHelperArchiveCaseSection (sectionId) {
'use strict'
let sectionText = await spiHelperGetPageText(spiHelperPageName, true, sectionId)
sectionText = sectionText.replace(spiHelperCaseStatusRegex, '')
const newarchivetext = sectionText.substring(sectionText.search(spiHelperSectionRegex))
// Update the archive
let archivetext = await spiHelperGetPageText(spiHelperGetArchiveName(), true)
if (!archivetext) {
archivetext = '__TOC__\n{{SPIarchive notice|1=' + spiHelperCaseName + '}}\n{{SPIpriorcases}}'
} else {
archivetext = archivetext.replace(/<br\s*\/>\s*{{SPIpriorcases}}/gi, '\n{{SPIpriorcases}}') // fmt fix whenever needed.
}
archivetext += '\n' + newarchivetext
const archiveSuccess = await spiHelperEditPage(spiHelperGetArchiveName(), archivetext,
'Archiving case section from [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]',
false, spiHelperSettings.watchArchive, spiHelperSettings.watchArchiveExpiry)
if (!archiveSuccess) {
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
$statusLine.addClass('spihelper-errortext').append('b').text('Failed to update archive, not removing section from case page')
return
}
// Blank the section we archived
await spiHelperEditPage(spiHelperPageName, '', 'Archiving case section to [[' + spiHelperGetInterwikiPrefix() + spiHelperGetArchiveName() + ']]',
false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID, sectionId)
// Update to the latest revision ID
spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName)
}
/**
* Move or merge the selected case into a different case
*
* @param {string} target The username portion of the case this section should be merged into
* (should have been normalized before getting passed in)
*/
async function spiHelperMoveCase (target) {
// Move or merge an entire case
// Normalize: change underscores to spaces
// target = target
const newPageName = spiHelperPageName.replace(spiHelperCaseName, target)
const targetPageText = await spiHelperGetPageText(newPageName, false)
if (targetPageText) {
if (spiHelperIsAdmin()) {
const proceed = confirm('Target page exists, do you want to histmerge the cases?')
if (!proceed) {
// Build out the error line
$('<li>')
.append($('<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
}
}
const oldPageName = spiHelperPageName
if (newPageName === oldPageName) {
$('<li>')
.append($('<div>').addClass('spihelper-errortext')
.append($('<b>').text('Target page is the current page, aborting merge.')))
.appendTo($('#spiHelper_status', document))
return
}
// Housekeeping to update all of the var names following the rename
const oldArchiveName = spiHelperGetArchiveName()
spiHelperCaseName = target
spiHelperPageName = newPageName
let archivesCopied = false
if (targetPageText) {
// There's already a page there, we're going to merge
// First, check if there's an archive; if so, copy its text over
const newArchiveName = spiHelperGetArchiveName().replace(spiHelperCaseName, target)
let sourceArchiveText = await spiHelperGetPageText(oldArchiveName, false)
let targetArchiveText = await spiHelperGetPageText(newArchiveName, false)
if (sourceArchiveText && targetArchiveText) {
$('<li>')
.append($('<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(spiHelperArchiveNoticeRegex, '')
sourceArchiveText = sourceArchiveText.replace(spiHelperPriorCasesRegex, '')
// Strip leading newlines
sourceArchiveText = sourceArchiveText.replace(/^\n*/, '')
targetArchiveText += '\n' + sourceArchiveText
await spiHelperEditPage(newArchiveName, targetArchiveText, 'Copying archives from [[' + spiHelperGetInterwikiPrefix() + oldArchiveName + ']], see page history for attribution',
false, spiHelperSettings.watchArchive, spiHelperSettings.watchArchiveExpiry)
await spiHelperDeletePage(oldArchiveName, 'Deleting copied archive')
archivesCopied = true
}
// Now get existing protection levels on the target and existing page.
const oldPageNameProtection = await spiHelperGetProtectionInformation(oldPageName)
const newPageNameProtection = await spiHelperGetProtectionInformation(spiHelperPageName)
const newProtectionValues = []
const siteProtectionInformation = await spiHelperGetSiteRestrictionInformation()
// First find if both the old page and new page had the same protection type enabled
siteProtectionInformation.types.forEach((type) => {
let oldPageNameEntry = oldPageNameProtection.filter((dict) => { return dict.type === type })
let newPageNameEntry = newPageNameProtection.filter((dict) => { return dict.type === type })
if (oldPageNameEntry.length > 0 && newPageNameEntry.length > 0) {
const newProtectionDict = { type: oldPageNameEntry.type }
oldPageNameEntry = oldPageNameEntry[0]
newPageNameEntry = newPageNameEntry[0]
if (newPageNameEntry.expiry === 'infinity' || oldPageNameEntry.expiry === 'infinity' || newPageNameEntry.expiry === 'infinite' || oldPageNameEntry.expiry === 'infinite') {
newProtectionDict.push({ expiry: 'infinite' })
} else if (newPageNameEntry.expiry < oldPageNameEntry.expiry) {
newProtectionDict.push({ expiry: oldPageNameEntry.expiry })
} else {
newProtectionDict.push({ expiry: newPageNameEntry.expiry })
}
const oldPageNameEntryLevelIndex = siteProtectionInformation.levels.indexOf(oldPageNameEntry.level)
const newPageNameEntryLevelIndex = siteProtectionInformation.levels.indexOf(newPageNameEntry.level)
if (oldPageNameEntryLevelIndex === -1 || newPageNameEntryLevelIndex === -1) {
console.error('Invalid protection information provided from API')
return
} else if (oldPageNameEntryLevelIndex > newPageNameEntryLevelIndex) {
newProtectionDict.push({ level: oldPageNameEntry.level })
} else if (oldPageNameEntryLevelIndex <= newPageNameEntryLevelIndex) {
newProtectionDict.push({ level: newPageNameEntry.level })
}
newProtectionValues.push(newProtectionDict)
} else if (oldPageNameEntry.length > 0) {
newProtectionValues.push(oldPageNameEntry[0])
} else if (newPageNameEntry.length > 0) {
newProtectionValues.push(newPageNameEntry[0])
}
})
// Now handle pending changes protection
const oldPageNameStabilisation = await spiHelperGetStabilisationSettings(oldPageName)
const newPageNameStabilisation = await spiHelperGetStabilisationSettings(spiHelperPageName)
let newStabilisationSettings = { protection_level: '' }
if (oldPageNameStabilisation !== false && newPageNameStabilisation !== false) {
// Pending changes is used on both pages
if (newPageNameStabilisation.protection_expiry === 'infinity' || oldPageNameStabilisation.protection_expiry === 'infinity' || newPageNameStabilisation.protection_expiry === 'infinite' || oldPageNameStabilisation.protection_expiry === 'infinite') {
newStabilisationSettings.push({ protection_expiry: 'infinite' })
} else if (newPageNameStabilisation.protection_expiry < oldPageNameStabilisation.expiry) {
newStabilisationSettings.push({ protection_expiry: oldPageNameStabilisation.protection_expiry })
} else {
newStabilisationSettings.push({ protection_expiry: newPageNameStabilisation.protection_expiry })
}
const oldPageNameEntryLevelIndex = siteProtectionInformation.levels.indexOf(oldPageNameStabilisation.protection_level)
const newPageNameEntryLevelIndex = siteProtectionInformation.levels.indexOf(newPageNameStabilisation.protection_level)
if (oldPageNameEntryLevelIndex === -1 || newPageNameEntryLevelIndex === -1) {
console.error('Invalid protection information provided from API')
return
} else if (oldPageNameEntryLevelIndex > newPageNameEntryLevelIndex) {
newStabilisationSettings.push({ level: oldPageNameStabilisation.protection_level })
} else if (oldPageNameEntryLevelIndex <= newPageNameEntryLevelIndex) {
newStabilisationSettings.push({ level: newPageNameStabilisation.protection_level })
}
} else if (oldPageNameStabilisation !== false) {
newStabilisationSettings = oldPageNameStabilisation
} else if (newPageNameStabilisation !== false) {
newStabilisationSettings = newPageNameStabilisation
}
// Ignore warnings on the move, we're going to get one since we're stomping an existing page
await spiHelperDeletePage(spiHelperPageName, 'Deleting as part of case merge')
await spiHelperMovePage(oldPageName, spiHelperPageName, 'Merging case to [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]', true)
await spiHelperUndeletePage(spiHelperPageName, 'Restoring page history after merge')
if (archivesCopied) {
// Create a redirect
spiHelperEditPage(oldArchiveName, '#REDIRECT [[' + newArchiveName + ']]', 'Redirecting old archive to new archive',
false, spiHelperSettings.watchArchive, spiHelperSettings.watchArchiveExpiry)
}
// Now to protect both the oldPageName and newPageName with the protection settings in newProtectionDict, unless it is empty (i.e. no protection needed)
// Also apply any pending changes needed (i.e. if newStabilisationSettings has a non-empty protection_level)
if (newProtectionValues.length !== 0) {
spiHelperProtectPage(spiHelperPageName, newProtectionValues)
spiHelperProtectPage(oldPageName, newProtectionValues)
}
if (newStabilisationSettings.protection_level !== '') {
spiHelperConfigurePendingChanges(spiHelperPageName, newStabilisationSettings)
spiHelperConfigurePendingChanges(oldPageName, newStabilisationSettings)
}
} else {
await spiHelperMovePage(oldPageName, spiHelperPageName, 'Moving case to [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']]', false)
}
spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName)
await spiHelperPostRenameCleanup(oldPageName)
if (targetPageText) {
// If there was a page there before, also need to do post-merge cleanup
await spiHelperPostMergeCleanup(targetPageText)
}
if (archivesCopied) {
alert('Archives were merged during the case move, please reorder the archive sections')
}
}
/**
* Move or merge a specific section of a case into a different case
*
* @param {string} target The username portion of the case this section should be merged into (pre-normalized)
* @param {!number} sectionId The section ID of this case that should be moved/merged
*/
async function spiHelperMoveCaseSection (target, sectionId) {
// Move or merge a particular section of a case
'use strict'
const newPageName = spiHelperPageName.replace(spiHelperCaseName, target)
let targetPageText = await spiHelperGetPageText(newPageName, false)
let sectionText = await spiHelperGetPageText(spiHelperPageName, true, sectionId)
// SOCK_SECTION_RE_WITH_NEWLINE cleans up extraneous whitespace at the top of the section
// Have to do this transform before concatenating with targetPageText so that the
// "originally filed" goes in the correct section
sectionText = sectionText.replace(spiHelperSockSectionWithNewlineRegex, '====Suspected sockpuppets====' +
'\n* {{checkuser|1=' + spiHelperCaseName + '}} ({{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
spiHelperEditPage(newPageName, targetPageText, 'Moving case section from [[' + spiHelperGetInterwikiPrefix() + spiHelperPageName + ']], see page history for attribution',
false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry)
// Blank the section we moved
await spiHelperEditPage(spiHelperPageName, '', 'Moving case section to [[' + spiHelperGetInterwikiPrefix() + newPageName + ']]',
false, spiHelperSettings.watchCase, spiHelperSettings.watchCaseExpiry, spiHelperStartingRevID, sectionId)
// Update to the latest revision ID
spiHelperStartingRevID = await spiHelperGetPageRev(spiHelperPageName)
}
/**
* Render a text box's contents and display it in the preview area
*
*/
async function spiHelperPreviewText () {
const inputText = $('#spiHelper_CommentText', document).val().toString()
const renderedText = await spiHelperRenderText(spiHelperPageName, inputText)
// Fill the preview box with the new text
const $previewBox = $('#spiHelper_previewBox', document)
$previewBox.html(renderedText)
// Unhide it if it was hidden
$previewBox.show()
}
/**
* Given a page title, get an API to operate on that page
*
* @param {string} title Title of the page we want the API for
* @return {Object} MediaWiki Api/ForeignAPI for the target page's wiki
*/
function spiHelperGetAPI (title) {
'use strict'
if (title.startsWith('m:') || title.startsWith('meta:')) {
return new mw.ForeignApi('https://meta.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 spiHelperStripXWikiPrefix (title) {
// TODO: This only works with single-colon names, make it more robust
'use strict'
if (title.startsWith('m:') || title.startsWith('meta:')) {
return title.slice(title.indexOf(':') + 1)
} else {
return title
}
}
/**
* Get the post-expand include size of a given page
*
* @param {string} title Page title to check
* @param {?number} sectionId Section to check, if null check the whole page
*
* @return {Promise<number>} Post-expand include size of the given page/page section
*/
async function spiHelperGetPostExpandSize (title, sectionId = null) {
// Synchronous method to get a page's post-expand include size given its title
const finalTitle = spiHelperStripXWikiPrefix(title)
const request = {
action: 'parse',
prop: 'limitreportdata',
page: finalTitle
}
if (sectionId) {
request.section = sectionId
}
const api = spiHelperGetAPI(title)
try {
const response = await api.get(request)
// The page might not exist, so we need to handle that smartly - only get the parse
// if the page actually parsed
if ('parse' in response) {
// Iterate over all properties to find the PEIS
for (let i = 0; i < response.parse.limitreportdata.length; i++) {
if (response.parse.limitreportdata[i].name === 'limitreport-postexpandincludesize') {
return response.parse.limitreportdata[i][0]
}
}
}
} catch (error) {
// Something's gone wrong, just return 0
}
return 0
}
/**
* Get the maximum post-expand size from the wgPageParseReport (it's the same for all pages)
*
* @return {number} The max post-expand size in bytes
*/
function spiHelperGetMaxPostExpandSize () {
'use strict'
return mw.config.get('wgPageParseReport').limitreport.postexpandincludesize.limit
}
/**
* Get the inter-wiki prefix for the current wiki
*
* @return {string} The inter-wiki prefix
*/
function spiHelperGetInterwikiPrefix () {
// Mostly copied from https://github.com/Xi-Plus/twinkle-global/blob/master/morebits.js
// Most of this should be overkill (since most of these wikis don't have checkuser support)
/** @type {string[]} */ const temp = mw.config.get('wgServer').replace(/^(https?:)?\/\//, '').split('.')
const wikiLang = temp[0]
const wikiFamily = temp[1]
switch (wikiFamily) {
case '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 spiHelperGetPageText (title, show, sectionId = null) {
const $statusLine = $('<li>')
if (show) {
// Actually display the statusLine
$('#spiHelper_status', document).append($statusLine)
}
// Build the link element (use JQuery so we get escapes and such)
const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Getting page ' + $link.prop('outerHTML'))
const finalTitle = spiHelperStripXWikiPrefix(title)
const request = {
action: 'query',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
indexpageids: true,
titles: finalTitle
}
if (sectionId) {
request.rvsection = sectionId
}
try {
const response = await spiHelperGetAPI(title).get(request)
const pageid = response.query.pageids[0]
if (pageid === '-1') {
$statusLine.html('Page ' + $link.html() + ' does not exist')
return ''
}
$statusLine.html('Got ' + $link.html())
return response.query.pages[pageid].revisions[0].slots.main['*']
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<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
*
* @return {Promise<boolean>} Whether the edit was successful
*/
async function spiHelperEditPage (title, newtext, summary, createonly, watch, watchExpiry = null, baseRevId = null, sectionId = null) {
let activeOpKey = 'edit_' + title
if (sectionId) {
activeOpKey += '_' + sectionId
}
spiHelperActiveOperations.set(activeOpKey, 'running')
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Editing ' + $link.prop('outerHTML'))
const api = spiHelperGetAPI(title)
const finalTitle = spiHelperStripXWikiPrefix(title)
const request = {
action: 'edit',
watchlist: watch,
summary: summary + spihelperAdvert,
text: newtext,
title: finalTitle,
createonly: createonly
}
if (sectionId) {
request.section = sectionId
}
if (watchExpiry) {
request.watchlistexpiry = watchExpiry
}
if (baseRevId) {
request.baserevid = baseRevId
}
try {
await api.postWithToken('csrf', request)
$statusLine.html('Saved ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
return true
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<b>Edit failed on ' + $link.html() + '</b>: ' + error)
console.error(error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
return false
}
}
/**
* Moves a page. Exactly what it sounds like.
*
* @param {string} sourcePage Title of the source page (page we're moving)
* @param {string} destPage Title of the destination page (page we're moving to)
* @param {string} summary Edit summary to use for the move
* @param {boolean} ignoreWarnings Whether to ignore warnings on move (used to force-move one page over another)
*/
async function spiHelperMovePage (sourcePage, destPage, summary, ignoreWarnings, moveSubpages = true) {
const activeOpKey = 'move_' + sourcePage + '_' + destPage
spiHelperActiveOperations.set(activeOpKey, 'running')
// Should never be a crosswiki call
const api = new mw.Api()
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $sourceLink = $('<a>').attr('href', mw.util.getUrl(sourcePage)).attr('title', sourcePage).text(sourcePage)
const $destLink = $('<a>').attr('href', mw.util.getUrl(destPage)).attr('title', destPage).text(destPage)
$statusLine.html('Moving ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML'))
try {
await api.postWithToken('csrf', {
action: 'move',
from: sourcePage,
to: destPage,
reason: summary + spihelperAdvert,
noredirect: false,
movesubpages: moveSubpages,
ignoreWarnings: ignoreWarnings
})
$statusLine.html('Moved ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<b>Failed to move ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML') + '</b>: ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
/**
* Purges a page's cache
*
*
* @param {string} title Title of the page to purge
*/
async function spiHelperPurgePage (title) {
// Forces a cache purge on the selected page
'use strict'
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Purging ' + $link.prop('outerHTML'))
const strippedTitle = spiHelperStripXWikiPrefix(title)
const api = spiHelperGetAPI(title)
try {
await api.postWithToken('csrf', {
action: 'purge',
titles: strippedTitle
})
$statusLine.html('Purged ' + $link.prop('outerHTML'))
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<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 spiHelperWikiBlockUser (user, duration, reason, reblock, anononly, accountcreation,
autoblock, talkpage, email, watchBlockedUser, watchExpiry) {
'use strict'
const activeOpKey = 'block_' + user
spiHelperActiveOperations.set(activeOpKey, 'running')
if (!watchExpiry) {
watchExpiry = 'indefinite'
}
const userPage = 'User:' + user
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $link = $('<a>').attr('href', mw.util.getUrl(userPage)).attr('title', userPage).text(user)
$statusLine.html('Blocking ' + $link.prop('outerHTML'))
// This is not something which should ever be cross-wiki
const api = new mw.Api()
try {
await api.postWithToken('csrf', {
action: 'block',
expiry: duration,
reason: reason,
reblock: reblock,
anononly: anononly,
nocreate: accountcreation,
autoblock: autoblock,
allowusertalk: !talkpage,
noemail: email,
watchuser: watchBlockedUser,
watchlistexpiry: watchExpiry,
user: user
})
$statusLine.html('Blocked ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
return true
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<b>Failed to block ' + $link.prop('outerHTML') + '</b>: ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
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 spiHelperGetUserBlockReason (user) {
'use strict'
// This is not something which should ever be cross-wiki
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
list: 'blocks',
bklimit: '1',
bkusers: user,
bkprop: 'user|reason'
})
if (response.query.blocks.length === 0) {
// If the length is 0, then the user isn't blocked
return ''
}
return response.query.blocks[0].reason
} catch (error) {
return ''
}
}
/**
* Get a user's current block settings
*
* @param {string} user Username
* @return {Promise<BlockEntry>} Current block settings for the user, or null if the user is not blocked
*/
async function spiHelperGetUserBlockSettings (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|flags|expiry'
})
if (response.query.blocks.length === 0) {
// If the length is 0, then the user isn't blocked
return null
}
/** @type {BlockEntry} */
const item = {
username: user,
duration: response.query.blocks[0].expiry,
acb: ('nocreate' in response.query.blocks[0] || 'anononly' in response.query.blocks[0]),
ab: 'autoblock' in response.query.blocks[0],
ntp: !('allowusertalk' in response.query.blocks[0]),
nem: 'noemail' in response.query.blocks[0],
tpn: ''
}
return item
} catch (error) {
return null
}
}
/**
* Get whether a user is currently globally locked
*
* @param {string} user Username
* @return {Promise<boolean>} Whether the user is globally locked
*/
async function spiHelperIsUserGloballyLocked (user) {
'use strict'
// This is not something which should ever be cross-wiki
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
list: 'globalallusers',
agulimit: '1',
agufrom: user,
aguto: user,
aguprop: 'lockinfo'
})
if (response.query.globalallusers.length === 0) {
// If the length is 0, then we couldn't find the global user
return false
}
// If the 'locked' field is present, then the user is locked
return 'locked' in response.query.globalallusers[0]
} catch (error) {
return false
}
}
async function spiHelperDoesUserExistLocally (user) {
'use strict'
// This should never be cross-wiki
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
list: 'allusers',
aulimit: '1',
aufrom: user,
auto: user
})
if (response.query.allusers.length === 0) {
// If the length is 0, then we couldn't find the local account so return false
return false
}
// Otherwise a local account exists so return true
return true
} catch (error) {
return false
}
}
async function spiHelperDoesUserExistGlobally (user) {
'use strict'
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
list: 'globalallusers',
agulimit: '1',
agufrom: user,
aguto: user
})
if (response.query.globalallusers.length === 0) {
// If the length is 0, then we couldn't find the global user so return false
return false
}
// Otherwise the global account exists so return true
return true
} 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 spiHelperGetPageRev (title) {
'use strict'
const finalTitle = spiHelperStripXWikiPrefix(title)
const request = {
action: 'query',
prop: 'revisions',
rvslots: 'main',
indexpageids: true,
titles: finalTitle
}
try {
const response = await spiHelperGetAPI(title).get(request)
const pageid = response.query.pageids[0]
if (pageid === '-1') {
return 0
}
return response.query.pages[pageid].revisions[0].revid
} catch (error) {
return 0
}
}
/**
* Delete a page. Admin-only function.
*
* @param {string} title Title of the page to delete
* @param {string} reason Reason to log for the page deletion
*/
async function spiHelperDeletePage (title, reason) {
'use strict'
const activeOpKey = 'delete_' + title
spiHelperActiveOperations.set(activeOpKey, 'running')
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Deleting ' + $link.prop('outerHTML'))
const api = spiHelperGetAPI(title)
try {
await api.postWithToken('csrf', {
action: 'delete',
title: title,
reason: reason
})
$statusLine.html('Deleted ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<b>Failed to delete ' + $link.prop('outerHTML') + '</b>: ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
/**
* Undelete a page (or, if the page exists, undelete deleted revisions). Admin-only function
*
* @param {string} title Title of the pgae to undelete
* @param {string} reason Reason to log for the page undeletion
*/
async function spiHelperUndeletePage (title, reason) {
'use strict'
const activeOpKey = 'undelete_' + title
spiHelperActiveOperations.set(activeOpKey, 'running')
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $link = $('<a>').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Undeleting ' + $link.prop('outerHTML'))
const api = spiHelperGetAPI(title)
try {
await api.postWithToken('csrf', {
action: 'undelete',
title: title,
reason: reason
})
$statusLine.html('Undeleted ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<b>Failed to undelete ' + $link.prop('outerHTML') + '</b>: ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
/**
* 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 spiHelperRenderText (title, text) {
'use strict'
const request = {
action: 'parse',
prop: 'text',
pst: 'true',
text: text,
title: title
}
try {
const response = await spiHelperGetAPI(title).get(request)
return response.parse.text['*']
} catch (error) {
console.error('Error rendering text: ' + error)
return ''
}
}
/**
* Get a list of investigations on the sockpuppet investigation page
*
* @return {Promise<Object[]>} An array of section objects, each section is a separate investigation
*/
async function spiHelperGetInvestigationSectionIDs () {
// Uses the parse API to get page sections, then find the investigation
// sections (should all be level-3 headers)
'use strict'
// Since this only affects the local page, no need to call spiHelper_getAPI()
const api = new mw.Api()
const response = await api.get({
action: 'parse',
prop: 'sections',
page: spiHelperPageName
})
const dateSections = []
for (let i = 0; i < response.parse.sections.length; i++) {
// TODO: also check for presence of spi case status
if (parseInt(response.parse.sections[i].level) === 3) {
dateSections.push(response.parse.sections[i])
}
}
return dateSections
}
/**
* Get SPI page backlinks to this SPI page.
* Used to fix double redirects when merging cases.
*/
async function spiHelperGetSPIBacklinks (casePageName) {
// Only looking for enwiki backlinks
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
format: 'json',
list: 'backlinks',
bltitle: casePageName,
blnamespace: '4',
bldir: 'ascending',
blfilterredir: 'nonredirects'
})
return response.query.backlinks.filter((dictEntry) => {
return dictEntry.title.startsWith('Wikipedia:Sockpuppet investigations/') && !dictEntry.title.startsWith('Wikipedia:Sockpuppet investigations/SPI/') && !dictEntry.title.match('Wikipedia:Sockpuppet investigations/.*/Archive.*')
})
} catch (error) {
return []
}
}
/**
* Get the page protection level for a SPI page.
* Used to keep the protection level after a history merge
*/
async function spiHelperGetProtectionInformation (casePageName) {
// Only looking for enwiki protection information
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
format: 'json',
prop: 'info',
titles: casePageName,
inprop: 'protection'
})
return response.query.pages[Object.keys(response.query.pages)[0]].protection
} catch (error) {
return []
}
}
/**
* Gets stabilisation settings information for a page. If no pending changes exists then it returns false.
*/
async function spiHelperGetStabilisationSettings (casePageName) {
// Only looking for enwiki stabilisation information
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
format: 'json',
prop: 'flagged',
titles: casePageName
})
const entry = response.query.pages[Object.keys(response.query.pages)[0]]
if ('flagged' in entry) {
return entry.flagged
} else {
return false
}
} catch (error) {
return false
}
}
async function spiHelperProtectPage (casePageName, protections) {
// Only lookint to protect pages on enwiki
const activeOpKey = 'protect_' + casePageName
spiHelperActiveOperations.set(activeOpKey, 'running')
const $statusLine = $('<li>').appendTo($('#spiHelper_status', document))
const $link = $('<a>').attr('href', mw.util.getUrl(casePageName)).attr('title', casePageName).text(casePageName)
$statusLine.html('Protecting ' + $link.prop('outerHTML'))
const api = new mw.Api()
try {
let protectlevelinfo = ''
let expiryinfo = ''
protections.forEach((dict) => {
if (protectlevelinfo !== '') {
protectlevelinfo = protectlevelinfo + '|'
expiryinfo = expiryinfo + '|'
}
protectlevelinfo = protectlevelinfo + dict.type + '=' + dict.level
expiryinfo = expiryinfo + dict.expiry
})
await api.postWithToken('csrf', {
action: 'protect',
format: 'json',
title: casePageName,
protections: protectlevelinfo,
expiry: expiryinfo,
reason: 'Restoring protection after history merge'
})
$statusLine.html('Protected ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('<b>Failed to protect ' + $link.prop('outerHTML') + '</b>: ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
async function spiHelperConfigurePendingChanges (casePageName, protectionLevel, protectionExpiry) {
// Only lookint to protect pages on enwiki
const activeOpKey = 'stabilize_' + casePageName
spiHelperActiveOperations.set(activeOpKey, 'running')
const api = new mw.Api()
try {
await api.postWithToken('csrf', {
action: 'stabilize',
format: 'json',
titles: casePageName,
protectlevel: protectionLevel,
expiry: protectionExpiry,
reason: 'Restoring pending changes protection after history merge'
})
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
async function spiHelperGetSiteRestrictionInformation () {
// For enwiki only as this is it's only use case
const api = new mw.Api()
try {
const response = await api.get({
action: 'query',
format: 'json',
meta: 'siteinfo',
siprop: 'restrictions'
})
return response.query.restrictions
} catch (error) {
return []
}
}
/**
* Parse given text as wikitext without it needing to be currently saved onwiki.
*
*/
async function spiHelperParseWikitext (wikitext) {
// For enwiki only for now
const api = new mw.Api()
try {
const response = await api.get({
action: 'parse',
prop: 'text',
text: wikitext,
wrapoutputclass: '',
disablelimitreport: 1,
disableeditsection: 1,
contentmodel: 'wikitext'
})
return response.parse.text['*']
} catch (error) {
return ''
}
}
/**
* Returns true if the date provided is a valid date for strtotime in PHP (determined by using the time parser function and a parse API call)
*/
async function spiHelperValidateDate (dateInStringFormat) {
const response = await spiHelperParseWikitext('{{#time:r|' + dateInStringFormat + '}}')
return !response.includes('Error: Invalid time.')
}
/**
* Pretty obvious - gets the name of the archive. This keeps us from having to regen it
* if we rename the case
*
* @return {string} Name of the archive page
*/
function spiHelperGetArchiveName () {
return spiHelperPageName + '/Archive'
}
// UI helper functions
/**
* Generate a line of the block table for a particular user
*
* @param {string} name Username for this block line
* @param {boolean} defaultblock Whether to check the block box by default on this row
* @param {number} id Index of this line in the block table
*/
async function spiHelperGenerateBlockTableLine (name, defaultblock, id) {
'use strict'
let currentBlock = null
if (name) {
currentBlock = await spiHelperGetUserBlockSettings(name)
}
let block, ab, acb, ntp, nem, duration
if (currentBlock) {
block = true
acb = currentBlock.acb
ab = currentBlock.ab
ntp = currentBlock.ntp
nem = currentBlock.nem
duration = currentBlock.duration
} else {
block = defaultblock
acb = true
ab = true
ntp = spiHelperArchiveNoticeParams.notalk
nem = spiHelperArchiveNoticeParams.notalk
duration = mw.util.isIPAddress(name, true) ? '1 week' : 'indefinite'
}
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', block)).appendTo($row)
// Block duration (only for admins)
$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'text')
.attr('id', 'spiHelper_block_duration' + id).val(duration)
.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', acb)).appendTo($row)
// Autoblock (only for admins)
$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_block_ab' + id).prop('checked', ab)).appendTo($row)
// Revoke talk page access (only for admins)
$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_block_tp' + id).prop('checked', ntp)).appendTo($row)
// Block email access (only for admins)
$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_block_email' + id).prop('checked', nem)).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
spiHelperGenerateSelect('spiHelper_block_tag' + id, spiHelperTagOptions)
spiHelperGenerateSelect('spiHelper_block_tag_altmaster' + id, spiHelperAltMasterTagOptions)
// Add onlistener events to update the global lock checkbox if the username is changed between a IP address and username
$('#spiHelper_block_username' + id).on('change', (event) => {
const id = $(event.target).attr('id').replace('spiHelper_block_username', '')
$('#spiHelper_block_lock' + id).prop('disabled', mw.util.isIPAddress($(event.target).val(), true))
})
}
async function spiHelperGenerateLinksTableLine (username, id) {
'use strict'
const $table = $('#spiHelper_userInfoTable', document)
const $row = $('<tr>')
// Username
$('<td>').append($('<input>').attr('type', 'text').attr('id', 'spiHelper_link_username' + id)
.val(username).addClass('.spihelper-widthlimit')).appendTo($row)
// Editor interaction analyser
$('<td>').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_link_editorInteractionAnalyser' + id)).attr('style', 'text-align:center;').appendTo($row)
// Interaction timeline
$('<td>').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_link_interactionTimeline' + id)).attr('style', 'text-align:center;').appendTo($row)
// SPI tools timecard tool
$('<td>').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_link_timecardSPITools' + id)).attr('style', 'text-align:center;').appendTo($row)
// SPI tools consilidated timeline (admin only based on OAUTH requirements)
$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_link_consolidatedTimelineSPITools' + id)).attr('style', 'text-align:center;').appendTo($row)
// SPI tools pages tool (admin only based on OAUTH requirements)
$('<td>').addClass('spiHelper_adminClass').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_link_pagesSPITools' + id)).attr('style', 'text-align:center;').appendTo($row)
// Checkuser wiki search (CU only)
$('<td>').addClass('spiHelper_cuClass').append($('<input>').attr('type', 'checkbox')
.attr('id', 'spiHelper_link_checkUserWikiSearch' + id)).attr('style', 'text-align:center;').appendTo($row)
$table.append($row)
}
/**
* Complicated function to decide what checkboxes to enable or disable
* and which to check by default
*/
async function spiHelperSetCheckboxesBySection () {
// Displays the top-level SPI menu
'use strict'
const $topView = $('#spiHelper_topViewDiv', document)
// Get the value of the selection box
if ($('#spiHelper_sectionSelect', $topView).val() === 'all') {
spiHelperSectionId = null
spiHelperSectionName = null
} else {
spiHelperSectionId = parseInt($('#spiHelper_sectionSelect', $topView).val().toString())
const $sectionSelect = $('#spiHelper_sectionSelect', $topView)
spiHelperSectionName = spiHelperCaseSections[$sectionSelect.prop('selectedIndex')].line
}
const $warningText = $('#spiHelper_warning', $topView)
$warningText.hide()
const $archiveBox = $('#spiHelper_Archive', $topView)
const $closeBox = $('#spiHelper_Close', $topView)
const $moveBox = $('#spiHelper_Move', $topView)
/*
const $blockBox = $('#spiHelper_BlockTag', $topView)
const $commentBox = $('#spiHelper_Comment', $topView)
const $caseActionBox = $('#spiHelper_Case_Action', $topView)
const $spiMgmtBox = $('#spiHelper_SpiMgmt', $topView)
// Start by unchecking everything
$archiveBox.prop('checked', false)
$blockBox.prop('checked', false)
$closeBox.prop('checked', false)
$commentBox.prop('checked', false)
$moveBox.prop('checked', false)
$caseActionBox.prop('checked', false)
$spiMgmtBox.prop('checked', false)
*/
// Enable optionally-disabled boxes
$closeBox.prop('disabled', false)
$archiveBox.prop('disabled', false)
// archivenotice sanity check
const pageText = await spiHelperGetPageText(spiHelperPageName, false)
const result = spiHelperArchiveNoticeRegex.exec(pageText)
if (!result) {
$warningText.append($('<b>').text('Can\'t find archivenotice template!'))
$warningText.show()
}
if (spiHelperSectionId === null) {
// Hide inputs that aren't relevant in the case view
$('.spiHelper_singleCaseOnly', $topView).hide()
// Show inputs only visible in all-case mode
$('.spiHelper_allCasesOnly', $topView).show()
// Fix the move label
$('#spiHelper_moveLabel', $topView).text('Move/merge full case (Clerk only)')
// enable the move box
$moveBox.prop('disabled', false)
} else {
const sectionText = await spiHelperGetPageText(spiHelperPageName, false, spiHelperSectionId)
if (!spiHelperSectionRegex.test(sectionText)) {
// Nothing to do here.
return
}
// Unhide single-case options
$('.spiHelper_singleCaseOnly', $topView).show()
// Hide inputs only visible in all-case mode
$('.spiHelper_allCasesOnly', $topView).hide()
const result = spiHelperCaseStatusRegex.exec(sectionText)
let casestatus = ''
if (result) {
casestatus = result[1]
} else if (!spiHelperIsThisPageAnArchive) {
$warningText.append($('<b>').text(`Can't find case status in ${spiHelperSectionName}!`))
$warningText.show()
}
// Disable the section move setting if you haven't opted into it
if (!spiHelperSettings.iUnderstandSectionMoves) {
$moveBox.prop('disabled', true)
$moveBox.prop('checked', false)
}
const isClosed = spiHelperCaseClosedRegex.test(casestatus)
if (isClosed) {
$closeBox.prop('disabled', true)
$closeBox.prop('checked', false)
if (spiHelperSettings.tickArchiveWhenCaseClosed) {
$archiveBox.prop('checked', true)
}
} else {
$archiveBox.prop('disabled', true)
$archiveBox.prop('checked', false)
$('#spiHelper_Case_Action', $topView).on('click', function () {
$('#spiHelper_Close', $topView).prop('disabled', $('#spiHelper_Case_Action', $topView).prop('checked'))
})
$('#spiHelper_Close', $topView).on('click', function () {
$('#spiHelper_Case_Action', $topView).prop('disabled', $('#spiHelper_Close', $topView).prop('checked'))
})
}
// 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>)')
}
// Only show options suitable for the archive subpage when running on the archives
if (spiHelperIsThisPageAnArchive) {
$('.spiHelper_notOnArchive', $topView).hide()
}
}
/**
* Updates whether the 'archive' checkbox is enabled
*/
function spiHelperUpdateArchive () {
// Archive should only be an option if close is checked or disabled (disabled meaning that
// the case is closed) and rename is not checked
'use strict'
$('#spiHelper_Archive', document).prop('disabled', !($('#spiHelper_Close', document).prop('checked') ||
$('#spiHelper_Close', document).prop('disabled')) || $('#spiHelper_Move', document).prop('checked'))
if ($('#spiHelper_Archive', document).prop('disabled')) {
$('#spiHelper_Archive', document).prop('checked', false)
}
}
/**
* Updates whether the 'move' checkbox is enabled
*/
function spiHelperUpdateMove () {
// Rename is mutually exclusive with archive
'use strict'
$('#spiHelper_Move', document).prop('disabled', $('#spiHelper_Archive', document).prop('checked'))
if ($('#spiHelper_Move', document).prop('disabled')) {
$('#spiHelper_Move', document).prop('checked', false)
}
}
/**
* Generate a select input, optionally with an onChange call
*
* @param {string} id Name of the input
* @param {SelectOption[]} options Array of options objects
*/
function spiHelperGenerateSelect (id, options) {
// Add the dates to the selector
const $selector = $('#' + id, document)
for (let i = 0; i < options.length; i++) {
const o = options[i]
$('<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 spiHelperSetAllTableColumnOpts (source, forTable) {
'use strict'
for (let i = 1; i <= (forTable === 'link' ? spiHelperLinkTableUserCount : spiHelperBlockTableUserCount); i++) {
const $target = $('#' + source.attr('id') + i)
if (source.attr('type') === 'checkbox') {
// Don't try to set disabled checkboxes
if (!$target.prop('disabled')) {
$target.prop('checked', source.prop('checked'))
}
} else {
$target.val(source.val())
}
}
}
/**
* Inserts text at the cursor's position
*
* @param {JQuery<HTMLElement>} source Select box that was changed
* @param {number?} pos Position to insert text; if null, inserts at the cursor
*/
function spiHelperInsertTextFromSelect (source, pos = null) {
const $textBox = $('#spiHelper_CommentText', document)
// https://stackoverflow.com/questions/11076975/how-to-insert-text-into-the-textarea-at-the-current-cursor-position
const selectionStart = parseInt($textBox.attr('selectionStart'))
const selectionEnd = parseInt($textBox.attr('selectionEnd'))
const startText = $textBox.val().toString()
const newText = source.val().toString()
if (pos === null && (selectionStart || selectionStart === 0)) {
$textBox.val(startText.substring(0, selectionStart) +
newText +
startText.substring(selectionEnd, startText.length))
$textBox.attr('selectionStart', selectionStart + newText.length)
$textBox.attr('selectionEnd', selectionEnd + newText.length)
} else if (pos !== null) {
$textBox.val(startText.substring(0, pos) +
source.val() +
startText.substring(pos, startText.length))
$textBox.attr('selectionStart', selectionStart + newText.length)
$textBox.attr('selectionEnd', selectionEnd + newText.length)
} else {
$textBox.val(startText + newText)
}
// Force the selected element to reset its selection to 0
source.prop('selectedIndex', 0)
}
/**
* Inserts a {{note}} template at the start of the text box
*
* @param {JQuery<HTMLElement>} source Select box that was changed
*/
function spiHelperInsertNote (source) {
'use strict'
const $textBox = $('#spiHelper_CommentText', document)
let newText = $textBox.val().toString()
// Match the start of the line, optionally including a '*' with or without whitespace around it,
// optionally including a template which contains the string "note"
newText = newText.replace(/^(\s*\*\s*)?({{[\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 spiHelperCaseActionUpdated (source) {
const $textBox = $('#spiHelper_CommentText', document)
let newText = $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 = '{{cu-endorsed}}'
break
case 'moreinfo': // Intentional fallthrough
case 'cumoreinfo':
newTemplate = '{{moreinfo}}'
break
case 'relist':
newTemplate = '{{relisted}}'
break
case 'hold':
case 'cuhold':
newTemplate = '{{onhold}}'
break
}
if (spiHelperClerkStatusRegex.test(newText)) {
newText = newText.replace(spiHelperClerkStatusRegex, newTemplate)
if (!newTemplate) { // If the new template is empty, get rid of the stray ' - '
newText = newText.replace(/^(\s*\*\s*)? - /, '$1')
}
} else if (newTemplate) {
// Don't try to insert if the "new template" is empty
// Also remove the leading *
newText = '*' + newTemplate + ' - ' + newText.replace(/^\s*\*\s*/, '')
}
$textBox.val(newText)
}
/**
* Fires on page load, adds the SPI portlet and (if the page is categorized as "awaiting
* archive," meaning that at least one closed template is on the page) the SPI-Archive portlet
*/
async function spiHelperAddLink () {
'use strict'
await spiHelperLoadSettings()
await mw.loader.load('mediawiki.util')
const initLink = mw.util.addPortletLink('p-cactions', '#', 'SPI', 'ca-spiHelper')
if (!initLink) return;
initLink.addEventListener('click', (e) => {
e.preventDefault()
return spiHelperInit()
})
if (mw.config.get('wgCategories').includes('SPI cases awaiting archive') && spiHelperIsClerk()) {
const 'p-cactions', '#', 'SPI-Archive', 'ca-spiHelperArchive')
$(oneClickArchiveLink).one('click', (e) => {
e.preventDefault()
return spiHelperOneClickArchive()
})
}
window.addEventListener('beforeunload', (e) => {
const $actionView = $('#spiHelper_actionViewDiv', document)
if ($actionView.length > 0) {
e.preventDefault()
// for Chrome
e.returnValue = ''
return true
}
// Make sure no operations are still in flight
let isDirty = false
spiHelperActiveOperations.forEach((value, _0, _1) => {
if (value === 'running') {
isDirty = true
}
})
if (isDirty) {
e.preventDefault()
e.returnValue = ''
return true
}
})
}
/**
* Checks for the existence of Special:MyPage/spihelper-options.js, and if it exists,
* loads the settings from that page.
*/
async function spiHelperLoadSettings () {
// Dynamically load a user's settings
// Borrowed from code I wrote for [[User:Headbomb/unreliable.js]]
try {
await mw.loader.getScript('/w/index.php?title=Special:MyPage/spihelper-options.js&action=raw&ctype=text/javascript')
if (typeof spiHelperCustomOpts !== 'undefined') {
const keys = Object.keys(spiHelperCustomOpts)
for (let index = 0; index < keys.length; index++) {
const k = keys[index]
const v = spiHelperCustomOpts[k]
if (k in spiHelperValidSettings) {
if (spiHelperValidSettings[k].indexOf(v) === -1) {
mw.log.warn('Invalid option given in spihelper-options.js for the setting ' + k.toString())
continue
}
} else if (k in spiHelperSettingsNeedingValidDate) {
if (!await spiHelperValidateDate(v)) {
mw.log.warn('Invalid option given in spihelper-options.js for the setting ' + k.toString())
continue
}
}
spiHelperSettings[k] = v
}
}
} catch (error) {
mw.log.error('Error retrieving your spihelper-options.js')
// More detailed error in the console
console.error('Error getting local spihelper-options.js: ' + error)
}
}
// User role helper functions
/**
* Whether the current user has admin permissions, used to determine
* whether to show block options
*
* @return {boolean} Whether the current user is an admin
*/
function spiHelperIsAdmin () {
if (spiHelperSettings.debugForceAdminState !== null) {
return spiHelperSettings.debugForceAdminState
}
return mw.config.get('wgUserGroups').includes('sysop')
}
/**
* Whether the current user has checkuser permissions, used to determine
* whether to show checkuser options
*
* @return {boolean} Whether the current user is a checkuser
*/
function spiHelperIsCheckuser () {
if (spiHelperSettings.debugForceCheckuserState !== null) {
return spiHelperSettings.debugForceCheckuserState
}
return mw.config.get('wgUserGroups').includes('checkuser')
}
/**
* Whether the current user is a clerk, used to determine whether to show
* clerk options
*
* @return {boolean} Whether the current user is a clerk
*/
function spiHelperIsClerk () {
// Assumption: checkusers should see clerk options. Please don't prove this wrong.
return spiHelperSettings.clerk || spiHelperIsCheckuser()
}
/**
* Common username normalization function
* @param {string} username Username to normalize
*
* @return {string} Normalized username
*/
function spiHelperNormalizeUsername (username) {
// Get rid of bad hidden characters
username = username.replace(spiHelperHiddenCharNormRegex, '')
// Remove leading and trailing spaces
username = username.trim()
if (mw.util.isIPAddress(username, true)) {
// For IP addresses, capitalize them (really only applies to IPv6)
username = username.toUpperCase()
} else if (username) {
// For actual usernames, make sure the first letter is capitalized
// Ensure consistent case conversions with PHP as per https://phabricator.wikimedia.org/T292824
username = new mw.Title(username).getMainText()
}
return username
}
// </nowiki>
/**
* Parse key features from an archivenotice
* @param {string} page Page to parse
*
* @return {Promise<ParsedArchiveNotice>} Parsed archivenotice
*/
async function spiHelperParseArchiveNotice (page) {
const pagetext = await spiHelperGetPageText(page, false)
const match = spiHelperArchiveNoticeRegex.exec(pagetext)
if (match === null) {
console.error('Missing archive notice')
return { username: null, deny: null, xwiki: null, notalk: null }
}
const username = match[1]
let deny = false
let xwiki = false
let notalk = false
if (match[2]) {
for (const entry of match[2].split('|')) {
if (!entry) {
// split in such a way that it's just a pipe
continue
}
const splitEntry = entry.split('=')
if (splitEntry.length !== 2) {
console.error('Malformed archivenotice parameter ' + entry)
continue
}
const key = splitEntry[0]
const val = splitEntry[1]
if (val.toLowerCase() !== 'yes') {
// Only care if the value is 'yes'
continue
}
if (key.toLowerCase() === 'deny') {
deny = true
} else if (key.toLowerCase() === 'crosswiki') {
xwiki = true
} else if (key.toLowerCase() === 'notalk') {
notalk = true
}
}
}
/** @type {ParsedArchiveNotice} */
return {
username: username,
deny: deny,
xwiki: xwiki,
notalk: notalk
}
}
/**
* Helper function to make a new archivenotice
* @param {string} username Username
* @param {ParsedArchiveNotice} archiveNoticeParams Other archivenotice params
*
* @return {string} New archivenotice
*/
function spiHelperMakeNewArchiveNotice (username, archiveNoticeParams) {
let notice = '{{SPIarchive notice|1=' + username
if (archiveNoticeParams.xwiki) {
notice += '|crosswiki=yes'
}
if (archiveNoticeParams.deny) {
notice += '|deny=yes'
}
if (archiveNoticeParams.notalk) {
notice += '|notalk=yes'
}
notice += '}}'
return notice
}
/**
* Function to add a blank user line to the block table
*
* Would fail ESlint no-unused-vars due to only being
* referenced in an onclick event
*
* @return {Promise<void>}
*/
// eslint-disable-next-line no-unused-vars
async function spiHelperAddBlankUserLine (tableName) {
if (tableName === 'block') {
spiHelperBlockTableUserCount++
await spiHelperGenerateBlockTableLine('', true, spiHelperBlockTableUserCount)
} else {
spiHelperLinkTableUserCount++
await spiHelperGenerateLinksTableLine('', spiHelperLinkTableUserCount)
}
updateForRole()
}