User:GeneralNotability/spihelper.js
//
// @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*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 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 = `
Handling SPI case
`
/**
* 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($('').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 = '
}
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]
$('
}
// All-sections selector...deliberately at the bottom, the default should be the first section
$('
// 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 = `
Handling SPI case
Changing case status
Changing SPI settings
Useful links for socks
Username | Interaction analyser | Interaction timeline | Timecard | Consolidated timeline | Pages | CU wiki |
---|---|---|---|---|---|---|
(All users) |
Blocking and tagging socks
-
Suppress the usual block summary and only use {{checkuserblock-account}} and {{checkuserblock}} (no effect if "mark blocks as CU blocks" is not checked).
Username | Blk? | Duration | ACB | AB/AO | NTP | NEM | Tag | Alt Master | Req Lock? |
---|---|---|---|---|---|---|---|---|---|
(All users) |
Marking case as closed
Move section
Archiving case
`
/**
* Big function to generate the SPI form from the top-level menu selections
*
* Would fail ESlint no-unused-vars due to only being
* referenced in an onclick event
*
* @return {Promise
*/
// eslint-disable-next-line no-unused-vars
async function spiHelperGenerateForm () {
'use strict'
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('
await spiHelperArchiveCase()
await spiHelperPurgePage(spiHelperPageName)
const logMessage = '* ' + spiHelperPageName + ': used one-click archiver ~~~~~'
if (spiHelperSettings.log) {
spiHelperLog(logMessage)
}
$('#spiHelper_status', document).append($('
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
*/
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 = $('
$statusLine.addClass('spihelper-errortext').html('The account ' + tagEntry.username + ' does not exist and so has not been tagged.')
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
*/
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 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 = $('
$statusLine.addClass('spihelper-errortext').html('Block failed on ' + blockEntry.username + ', not adding talk page notice')
}
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('
Generated links
Running actions
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 = $('
const $statusLineLink = $('').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') : '' + 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($('
}
if (locked) {
logMessage += '\n** requested locks for ' + locked
}
}
loggingPromise = Promise.all([blockingPromises, taggingPromises])
}
if (spiHelperSectionId && comment && comment !== '*' && !spiHelperIsThisPageAnArchive) {
if (!sectionText.includes('\n----')) {
sectionText.replace('', '')
sectionText.replace('', '')
sectionText += '\n----'
}
if (!/~~~~/.test(comment)) {
comment += ' ~~~~'
}
// Clerks and admins post in the admin section
if (spiHelperIsClerk() || spiHelperIsAdmin()) {
// Complicated regex to find the first regex in the admin section
// The weird (\n|.) is because we can't use /s (dot matches newline) regex mode without ES9,
// I don't want to go there yet
sectionText = sectionText.replace(/\n*----(?!(\n|.)*----)/, '\n' + comment + '\n----')
} else { // Everyone else posts in the "other users" section
sectionText = sectionText.replace(spiHelperAdminSectionWithPrecedingNewlinesRegex,
'\n' + comment + '\n====Clerk, CheckUser, and/or patrolling admin comments====\n')
}
if (editsummary) {
editsummary += ', comment'
} else {
editsummary = 'Comment'
}
logMessage += '\n** commented'
}
if (spiHelperActionsSelected.Close) {
newCaseStatus = 'close'
if (editsummary) {
editsummary += ', marking case as closed'
} else {
editsummary = 'Marking case as closed'
}
logMessage += '\n** closed case'
}
if (spiHelperSectionId !== null && !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 !== '*') {
$('
.append($('
.append($('').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($('
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 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*)'
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*
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(/
\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 = $('
$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
$('
.append($('
.append($('').text('Aborted merge.')))
.appendTo($('#spiHelper_status', document))
return
}
} else {
$('
.append($('
.append($('').text('Target page exists and you are not an admin, aborting merge.')))
.appendTo($('#spiHelper_status', document))
return
}
}
const oldPageName = spiHelperPageName
if (newPageName === oldPageName) {
$('
.append($('
.append($('').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) {
$('
.append($('
.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 = '
}
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
*/
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
*/
async function spiHelperGetPageText (title, show, sectionId = null) {
const $statusLine = $('
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 = $('').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Getting page ' + $link.prop('outerHTML'))
const finalTitle = spiHelperStripXWikiPrefix(title)
const request = {
action: 'query',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
indexpageids: true,
titles: finalTitle
}
if (sectionId) {
request.rvsection = sectionId
}
try {
const response = await spiHelperGetAPI(title).get(request)
const pageid = response.query.pageids[0]
if (pageid === '-1') {
$statusLine.html('Page ' + $link.html() + ' does not exist')
return ''
}
$statusLine.html('Got ' + $link.html())
return response.query.pages[pageid].revisions[0].slots.main['*']
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('Failed to get ' + $link.html() + ': ' + error)
return ''
}
}
/**
*
* @param {string} title Title of the page to edit
* @param {string} newtext New content of the page
* @param {string} summary Edit summary to use for the edit
* @param {boolean} createonly Only try to create the page - if false,
* will fail if the page already exists
* @param {string} watch What watchlist setting to use when editing - decides
* whether the edited page will be watched
* @param {string} watchExpiry Duration to watch the edited page, if unset
* defaults to 'indefinite'
* @param {?number} baseRevId Base revision ID, used to detect edit conflicts. If null,
* we'll grab the current page ID.
* @param {?number} [sectionId=null] Section to edit - if null, edits the whole page
*
* @return {Promise
*/
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 = $('
const $link = $('').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('Edit failed on ' + $link.html() + ': ' + error)
console.error(error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
return false
}
}
/**
* Moves a page. Exactly what it sounds like.
*
* @param {string} sourcePage Title of the source page (page we're moving)
* @param {string} destPage Title of the destination page (page we're moving to)
* @param {string} summary Edit summary to use for the move
* @param {boolean} ignoreWarnings Whether to ignore warnings on move (used to force-move one page over another)
*/
async function spiHelperMovePage (sourcePage, destPage, summary, ignoreWarnings, moveSubpages = true) {
const activeOpKey = 'move_' + sourcePage + '_' + destPage
spiHelperActiveOperations.set(activeOpKey, 'running')
// Should never be a crosswiki call
const api = new mw.Api()
const $statusLine = $('
const $sourceLink = $('').attr('href', mw.util.getUrl(sourcePage)).attr('title', sourcePage).text(sourcePage)
const $destLink = $('').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('Failed to move ' + $sourceLink.prop('outerHTML') + ' to ' + $destLink.prop('outerHTML') + ': ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
/**
* Purges a page's cache
*
*
* @param {string} title Title of the page to purge
*/
async function spiHelperPurgePage (title) {
// Forces a cache purge on the selected page
'use strict'
const $statusLine = $('
const $link = $('').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Purging ' + $link.prop('outerHTML'))
const strippedTitle = spiHelperStripXWikiPrefix(title)
const api = spiHelperGetAPI(title)
try {
await api.postWithToken('csrf', {
action: 'purge',
titles: strippedTitle
})
$statusLine.html('Purged ' + $link.prop('outerHTML'))
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('Failed to purge ' + $link.prop('outerHTML') + ': ' + error)
}
}
/**
* Blocks a user.
*
* @param {string} user Username to block
* @param {string} duration Duration of the block
* @param {string} reason Reason to log for the block
* @param {boolean} reblock Whether to reblock - if false, nothing will happen if the target user is already blocked
* @param {boolean} anononly For IPs, whether this is an anonymous-only block (alternative is
* that logged-in users with the IP are also blocked)
* @param {boolean} accountcreation Whether to permit the user to create new accounts
* @param {boolean} autoblock Whether to apply an autoblock to the user's IP
* @param {boolean} talkpage Whether to revoke talkpage access
* @param {boolean} email Whether to block email
* @param {boolean} watchBlockedUser Watchlist setting for whether to watch the newly-blocked user
* @param {string} watchExpiry Duration to watch the blocked user, if unset
* defaults to 'indefinite'
* @return {Promise
*/
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 = $('
const $link = $('').attr('href', mw.util.getUrl(userPage)).attr('title', userPage).text(user)
$statusLine.html('Blocking ' + $link.prop('outerHTML'))
// This is not something which should ever be cross-wiki
const api = new mw.Api()
try {
await api.postWithToken('csrf', {
action: 'block',
expiry: duration,
reason: reason,
reblock: reblock,
anononly: anononly,
nocreate: accountcreation,
autoblock: autoblock,
allowusertalk: !talkpage,
noemail: email,
watchuser: watchBlockedUser,
watchlistexpiry: watchExpiry,
user: user
})
$statusLine.html('Blocked ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
return true
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('Failed to block ' + $link.prop('outerHTML') + ': ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
return false
}
}
/**
* Get whether a user is currently blocked
*
* @param {string} user Username
* @return {Promise
*/
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
- /
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
*/
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
*/
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 = $('
const $link = $('').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Deleting ' + $link.prop('outerHTML'))
const api = spiHelperGetAPI(title)
try {
await api.postWithToken('csrf', {
action: 'delete',
title: title,
reason: reason
})
$statusLine.html('Deleted ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('Failed to delete ' + $link.prop('outerHTML') + ': ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
/**
* Undelete a page (or, if the page exists, undelete deleted revisions). Admin-only function
*
* @param {string} title Title of the pgae to undelete
* @param {string} reason Reason to log for the page undeletion
*/
async function spiHelperUndeletePage (title, reason) {
'use strict'
const activeOpKey = 'undelete_' + title
spiHelperActiveOperations.set(activeOpKey, 'running')
const $statusLine = $('
const $link = $('').attr('href', mw.util.getUrl(title)).attr('title', title).text(title)
$statusLine.html('Undeleting ' + $link.prop('outerHTML'))
const api = spiHelperGetAPI(title)
try {
await api.postWithToken('csrf', {
action: 'undelete',
title: title,
reason: reason
})
$statusLine.html('Undeleted ' + $link.prop('outerHTML'))
spiHelperActiveOperations.set(activeOpKey, 'success')
} catch (error) {
$statusLine.addClass('spihelper-errortext').html('Failed to undelete ' + $link.prop('outerHTML') + ': ' + error)
spiHelperActiveOperations.set(activeOpKey, 'failed')
}
}
/**
* Render a snippet of wikitext
*
* @param {string} title Page title
* @param {string} text Text to render
* @return {Promise
*/
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
*/
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 = $('
const $link = $('').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('Failed to protect ' + $link.prop('outerHTML') + ': ' + 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 = $('
// Username
$('
.val(name).addClass('.spihelper-widthlimit')).appendTo($row)
// Block checkbox (only for admins)
$('
.attr('id', 'spiHelper_block_doblock' + id).prop('checked', block)).appendTo($row)
// Block duration (only for admins)
$('
.attr('id', 'spiHelper_block_duration' + id).val(duration)
.addClass('.spihelper-widthlimit')).appendTo($row)
// Account creation blocked (only for admins)
$('
.attr('id', 'spiHelper_block_acb' + id).prop('checked', acb)).appendTo($row)
// Autoblock (only for admins)
$('
.attr('id', 'spiHelper_block_ab' + id).prop('checked', ab)).appendTo($row)
// Revoke talk page access (only for admins)
$('
.attr('id', 'spiHelper_block_tp' + id).prop('checked', ntp)).appendTo($row)
// Block email access (only for admins)
$('
.attr('id', 'spiHelper_block_email' + id).prop('checked', nem)).appendTo($row)
// Tag select box
$('