コンテンツにスキップ

英文维基 | 中文维基 | 日文维基 | 草榴社区

「MediaWiki:Gadget-MarkBLocked-core.js」の版間の差分

削除された内容 追加された内容
m Dragoniez がページ「MediaWiki:Gadget-MarkBLocked.js」を「MediaWiki:Gadget-MarkBLocked-core.js」に移動しました: モジュール化
書き直しによる機能改良
1行目: 1行目:
// @ts-check
/**
/* eslint-disable @typescript-eslint/no-this-alias */
* Gadget-MarkBLocked (GMBL)
/* global mw, OO */
* @author Dragoniez
* @link https://www.mediawiki.org/wiki/User:Dragoniez/Gadget-MarkBLocked.js
* @link https://www.mediawiki.org/wiki/User:Dragoniez/Gadget-MarkBLocked.css
* @license MIT
* @requires Gadget-MarkBLocked.css
* @description
* This is a script forked from [[m:User:Dragoniez/Mark BLocked Global.js]]. This script:
* (1) Marks up locally blocked users and single IPs.
* (2) Can mark up single IPs included in locally blocked IP ranges.
* (3) Can mark up globally locked users.
* (4) Can mark up globally blocked single IPs and IP ranges.
* Note that the features in (2)-(4) require quite some API calls and could lead to performance
* issues depending on the browser and computer environments of the editor who uses the script;
* hence disabled by default. You can enable them via the configuration page added by the script,
* namely via [[Special:MarkBLockedPreferences]] (and also [[Special:MBLP]] or [[Special:MBP]]).
*/
//<nowiki>
//<nowiki>
module.exports = /** @class */ (function() {
// var MarkBLocked = /** @class */ (function() {


/** @readonly */
(function(mw, $) { // Wrapper function
var defaultOptionKey = 'userjs-markblocked-config';


/**
// *******************************************************************************************************************
* @typedef UserOptions
* @type {object}
* @property {boolean} localips
* @property {boolean} globalusers
* @property {boolean} globalips
*/
/**
* @typedef ConstructorConfig
* @type {object}
* @property {string} [optionKey] The key of `mw.user.options`, defaulted to `userjs-markblocked-config`.
* @property {Object.<string, Lang>} [i18n] A language object to merge to {@link MarkBLocked.i18n}. Using this config makes
* it possible to configure the default interface messages and add a new interface language (for the latter, a value needs
* to be passed to the {@link lang} parameter.)
* @property {string} [lang] The code of the language to use in the interface messages, defaulted to `en`.
* @property {string[]} [contribs_CA] Special page aliases for Contributions and CentralAuth in the local language (no need
* to pass `Contributions`, `Contribs`, `CentralAuth`, `CA`, and `GlobalAccount`). If not provided, aliases are fetched from
* the API.
* @property {string[]} [groupsAHL] Local user groups with the `apihighlimits` user right, defaulted to `['sysop', 'bot']`;
*/
/**
* Initialize the properties of the `MarkBLocked` class. This is only to be called by `MarkBLocked.init`.
* @param {ConstructorConfig} [config]
* @constructor
* @requires mw.user
*/
function MarkBLocked(config) {


var cfg = config || {};
var api;
/** @readonly */
var MarkBLocked = mw.libs.MarkBLocked = {


// User options
// ********************************************** LOCALIZATION SETTINGS **********************************************
var defaultOptions = {
localips: false,
globalusers: true,
globalips: false
};
/**
* The key of `mw.user.options`.
* @readonly
*/
this.optionKey = cfg.optionKey || defaultOptionKey;
var /** @type {string} */ optionsStr = mw.user.options.get(this.optionKey) || '{}';
var /** @type {UserOptions} */ options;
try {
options = JSON.parse(optionsStr);
}
catch(err) {
console.error(err);
options = defaultOptions;
}
/** @type {UserOptions} */
this.options = $.extend(defaultOptions, options);


// Language options
/**
if (cfg.i18n) {
* Portletlink configurations
$.extend(MarkBLocked.i18n, cfg.i18n);
* @static
}
* @readonly
var langCode = 'en';
*/
if (cfg.lang) {
portletlink: {
if (Object.keys(MarkBLocked.i18n).indexOf(cfg.lang) !== -1) {
position: 'p-tb',
langCode = cfg.lang;
text: 'MarkBLockedの設定',
} else {
id: 't-gmblp',
console.error('"' + cfg.lang + '" is not available as the interface language of MarkBLocked.');
tooltip: 'MarkBLockedの設定を変更する',
}
accesskey: null,
}
nextnode: null
/** @type {Lang} */
},
this.msg = MarkBLocked.i18n[langCode];


// Regex to collect user links
/**
var wgNamespaceIds = mw.config.get('wgNamespaceIds'); // {"special": -1, "user": 2, ...}
* Register all local page names for [[Special:Contributions]] and [[Special:CentralAuth]] (without the namespace prefix).
var /** @type {string[]} */ specialAliases = [];
* 'contribs', 'contributions', 'ca', and 'centralauth' are registered by default: No need to register them. Note that the
var /** @type {string[]} */ userAliases = [];
* items are case-insensitive, compatible both with " " and "_" for spaces, and should NEVER be URI-encoded. If nothing
for (var alias in wgNamespaceIds) {
* needs to be registered, leave the array empty.
var namespaceId = wgNamespaceIds[alias];
* @static
switch(namespaceId) {
* @readonly
case -1:
*/
specialAliases.push(alias);
contribs_CA: ['投稿記録', 'アカウント統一管理'], // Example setting for jawiki
break;
case 2:
case 3:
userAliases.push(alias);
break;
default:
}
}
var rContribsCA = cfg.contribs_CA && cfg.contribs_CA.length ? '|' + cfg.contribs_CA.join('|') : '';
rContribsCA = '(?:' + specialAliases.join('|') + '):(?:contrib(?:ution)?s|ca|centralauth|globalaccount' + rContribsCA + ')/';
var rUser = '(?:' + userAliases.join('|') + '):';
/**
* Regular expressions to collect user links.
* @typedef LinkRegex
* @type {object}
* @property {RegExp} article `/wiki/PAGENAME`: $1: PAGENAME
* @property {RegExp} script `/w/index.php?title=PAGENAME`: $1: PAGENAME
* @property {RegExp} user `User:(USERNAME|CIDR)`: $1: USERNAME or CIDR
*/
/** @type {LinkRegex} */
this.regex = {
article: new RegExp(mw.config.get('wgArticlePath').replace('$1', '([^#?]+)')),
script: new RegExp(mw.config.get('wgScript') + '\\?title=([^#&]+)'),
user: new RegExp('^(?:' + rContribsCA + '|' + rUser + ')([^/#]+|[a-f\\d:\\.]+/\\d\\d)$', 'i')
};


// Validate apihighlimits
/**
var groupsAHLLocal = cfg.groupsAHL || ['sysop', 'bot'];
* Texts to show on [[Special:MarkBLockedPreferences]]
var groupsAHLGlobal = [
* @static
'apihighlimits-requestor',
* @readonly
'founder',
*/
'global-bot',
configpage: {
// 'global-sysop',
heading: 'MarkBLockedの設定',
'staff',
check: {
'steward',
localips: 'ブロックされたIPレンジに含まれる単一IPをマークアップする',
'sysadmin',
globalusers: 'グローバルロックを受けた利用者をマークアップする',
'wmf-researcher'
globalips: 'グローバルブロックを受けたIPをマークアップする'
];
var groupsAHL = groupsAHLLocal.concat(groupsAHLGlobal);
// @ts-ignore
var hasAHL = mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups') || []).some(function(group) {
return groupsAHL.indexOf(group) !== -1;
});
/**
* The maximum number of batch parameter values for the API.
* @type {500|50}
*/
this.apilimit = hasAHL ? 500 : 50;


}
},
save: {
button: '保存',
doing: '保存しています',
done: '保存しました',
failed: '保存に失敗しました',
lastsave: '最終保存:' // This is FOLLOWED by a space and a timestamp
}
},


/**
/**
* @typedef Lang
* Names of the local user groups that have the 'apihighlimits' user right
* @type {object}
* @static
* @property {string} config-notloaded
* @readonly
* @property {string} config-heading
*/
* @property {string} config-label-fieldset
apihighlimits: ['bot', 'sysop'],
* @property {string} config-label-localips
* @property {string} config-label-globalusers
* @property {string} config-label-globalips
* @property {string} config-label-save
* @property {string} config-label-saving
* @property {string} config-label-savedone
* @property {string} config-label-savefailed
* @property {string} portlet-text
*/
/**
* @type {Object.<string, Lang>}
* @static
*/
MarkBLocked.i18n = {
en: {
'config-notloaded': 'Failed to load the interface.',
'config-heading': 'Configure MarkBLocked',
'config-label-fieldset': 'Markup settings',
'config-label-localips': 'Mark up IPs in locally blocked IP ranges',
'config-label-globalusers': 'Mark up globally locked users',
'config-label-globalips': 'Mark up globally blocked IPs',
'config-label-save': 'Save settings',
'config-label-saving': 'Saving settings...',
'config-label-savedone': 'Sucessfully saved the settings.',
'config-label-savefailed': 'Failed to save the settings. ',
'portlet-text': 'Configure MarkBLocked'
},
ja: {
'config-notloaded': 'インターフェースの読み込みに失敗しました。',
'config-heading': 'MarkBLockedの設定',
'config-label-fieldset': 'マークアップ設定',
'config-label-localips': 'ブロックされたIPレンジに含まれるIPをマークアップ',
'config-label-globalusers': 'グローバルロックされた利用者をマークアップ',
'config-label-globalips': 'グローバルブロックされたIPをマークアップ',
'config-label-save': '設定を保存',
'config-label-saving': '設定を保存中...',
'config-label-savedone': '設定の保存に成功しました。',
'config-label-savefailed': '設定の保存に失敗しました。',
'portlet-text': 'MarkBLockedの設定'
}
};


/**
// *******************************************************************************************************************
* Get an interface message of MarkBLocked.
* @param {keyof Lang} key
* @returns {string}
*/
MarkBLocked.prototype.getMessage = function(key) {
return this.msg[key];
};


/**
/**
* @type {mw.Api}
* The keys are namespace numbers. The values are arrays of corresponding aliases.
* @readonly
* ```
*/
* console.log(nsAliases[3]); // ['user_talk'] - Always in lowercase and spaces are represented by underscores.
var api;
* ```
/**
* @type {Object.<number, Array<string>>}
* Initialize `MarkBLocked`.
* @static
* @param {ConstructorConfig} [config]
* @readonly
* @returns {JQueryPromise<MarkBLocked>}
*/
* @static
nsAliases: (function() {
*/
/** @type {Object.<string, number>} */
MarkBLocked.init = function(config) {
var nsObj = mw.config.get('wgNamespaceIds'); // {"special": -1, "user": 2, ...}
/** @type {Object.<number, Array<string>>} */
var obj = Object.create(null);
return Object.keys(nsObj).reduce(function(acc, alias) {
var nsNumber = nsObj[alias];
if (!acc[nsNumber]) {
acc[nsNumber] = [alias];
} else {
acc[nsNumber].push(alias);
}
return acc;
}, obj);
})(),


var cfg = config || {};
/**
* Get all namespace aliases associated with certain numbers. The aliases are in lowercase and spaces are represented by underscores.
* @param {Array<number>} nsNumberArray
* @param {string} [stringifyWith] Join the result array with this delimiter and retun a string if provided
* @returns {Array<string>|string}
*/
getAliases: function(nsNumberArray, stringifyWith) {
/** @type {Array<string>} */
var aliasesArr = [];
nsNumberArray.forEach(function(nsNumber) {
aliasesArr = aliasesArr.concat(MarkBLocked.nsAliases[nsNumber]);
});
return typeof stringifyWith === 'string' ? aliasesArr.join(stringifyWith) : aliasesArr;
},


// Wait for dependent modules and the DOM to get ready
hasApiHighlimits: false,
var modules = [
'mediawiki.user',
'mediawiki.api',
'mediawiki.util',
'oojs-ui',
'oojs-ui.styles.icons-moderation',
];
var onConfig = mw.config.get('wgNamespaceNumber') === -1 && /^(markblockedconfig|mblc)$/i.test(mw.config.get('wgTitle'));
if (!onConfig) {
modules.splice(3);
}
return $.when(
mw.loader.using(modules),
$.ready
).then(function() { // When ready


api = new mw.Api();
prefs: {
localips: false,
globalusers: false,
globalips: false
},


// For backwards compatibility, clear old config if any
/**
var oldOptionKey = 'userjs-gmbl-preferences';
* @static
var /** @type {string?} */ oldCfgStr = mw.user.options.get(oldOptionKey);
* @readonly
var /** @type {JQueryPromise<void>} */ backwards;
*/
if (oldCfgStr && (cfg.optionKey === void 0 || cfg.optionKey === defaultOptionKey) && !mw.user.options.get(defaultOptionKey)) {
saveOptionName: 'userjs-gmbl-preferences',
var /** @type {Record<string, string?>} */ params = {};
params[oldOptionKey] = null;
params[defaultOptionKey] = oldCfgStr;
backwards = api.saveOptions(params).then(function() {
mw.user.options.set(oldOptionKey, null);
mw.user.options.set(defaultOptionKey, oldCfgStr);
});
} else {
backwards = $.Deferred().resolve();
}


// Entry point
/**
var /** @type {JQueryPromise<string[]?>} */ ccaDeferred =
* @requires mediawiki.user
onConfig ?
* @requires mediawiki.util
$.Deferred().resolve([]) :
* @requires mediawiki.api
cfg.contribs_CA ?
*/
$.Deferred().resolve(cfg.contribs_CA) :
init: function() {
MarkBLocked.getContribsCA();
return $.when(ccaDeferred, backwards).then(function(contribs_CA) {


if (contribs_CA) {
api = new mw.Api();
cfg.contribs_CA = contribs_CA;
} else {
console.warn('MarkBLocked: Failed to get special page aliases.');
cfg.contribs_CA = [];
}


// Initialize MarkBLocked.hasApiHighlimits
var MBL = new MarkBLocked(cfg);
if (onConfig) {
var userGroups = MarkBLocked.apihighlimits.concat([
MBL.createConfigInterface();
'apihighlimits-requestor',
} else {
'founder',
MBL.createPortletLink();
'global-bot',
var /** @type {NodeJS.Timeout} */ hookTimeout;
'global-sysop',
mw.hook('wikipage.content').add(/** @param {JQuery<HTMLElement>} $content */ function($content) {
'staff',
clearTimeout(hookTimeout); // Prevent hook from being triggered multiple times
'steward',
hookTimeout = setTimeout(function() {
'sysadmin',
api.abort(); // Prevent the old HTTP requests from being taken over to the new markup procedure
'wmf-researcher'
MBL.markup($content);
]);
}, 100);
MarkBLocked.hasApiHighlimits = mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups')).some(function(group) {
});
return userGroups.indexOf(group) !== -1;
}
});
return MBL;


});
// Merge preferences
var prefs = mw.user.options.get(MarkBLocked.saveOptionName);
if (prefs) $.extend(MarkBLocked.prefs, JSON.parse(prefs));


});
// Are we on the preferences page?
if (mw.config.get('wgNamespaceNumber') === -1 && /^(markblockedpreferences|mbl?p)$/i.test(mw.config.get('wgTitle'))) {
return MarkBLocked.createPreferencesPage();
}


};
// If not, create a portletlink to the preferences page
mw.util.addPortletLink(
MarkBLocked.portletlink.position,
mw.config.get('wgArticlePath').replace('$1', 'Special:MarkBLockedPreferences'),
MarkBLocked.portletlink.text,
MarkBLocked.portletlink.id,
MarkBLocked.portletlink.tooltip,
MarkBLocked.portletlink.accesskey,
MarkBLocked.portletlink.nextnode
);


/**
// Now prepare for markup on certain conditions
* Get special page aliases for `Contributions` and `CentralAuth`.
if (mw.config.get('wgAction') !== 'edit' || // Not on an edit page, or
* @returns {JQueryPromise<string[]?>}
document.querySelector('.mw-logevent-loglines') // There's a notification box for delete, block, etc.
* @requires mediawiki.api
) {
* @static
var hookTimeout;
*/
mw.hook('wikipage.content').add(function() {
MarkBLocked.getContribsCA = function() {
clearTimeout(hookTimeout); // Prevent hook from being triggered multiple times
return api.get({
hookTimeout = setTimeout(MarkBLocked.collectUserLinks, 100);
action: 'query',
});
meta: 'siteinfo',
}
siprop: 'specialpagealiases',
formatversion: '2'
}).then(function(res) {
var resSpa = res && res.query && res.query.specialpagealiases;
if (Array.isArray(resSpa)) {
return resSpa.reduce(
/**
* @param {string[]} acc
* @param {{realname: string; aliases: string[];}} obj
* @returns
*/
function(acc, obj) {
var /** @type {string[]} */ exclude = [];
switch(obj.realname) {
case 'Contributions':
exclude = ['Contributions', 'Contribs'];
break;
case 'CentralAuth':
exclude = ['CentralAuth', 'CA', 'GlobalAccount'];
}
if (exclude.length) {
var aliases = obj.aliases.filter(function(alias) {
return exclude.indexOf(alias) === -1;
});
acc.concat(aliases);
}
return acc;
},
[]
);
} else {
return null;
}
}).catch(function(_, err) {
console.warn(err);
return null;
});
};


/**
},
* Replace the page content with the MarkBLocked config interface.
* @returns {void}
* @requires oojs-ui
* @requires oojs-ui.styles.icons-moderation
* @requires mediawiki.api
* @requires mediawiki.user
*/
MarkBLocked.prototype.createConfigInterface = function() {


document.title = 'MarkBLockedConfig - ' + mw.config.get('wgSiteName');
/**
* @static
* @readonly
*/
images: {
loading: '<img src="//upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif" style="vertical-align: middle; height: 1em; border: 0;">',
check: '<img src="//upload.wikimedia.org/wikipedia/commons/f/fb/Yes_check.svg" style="vertical-align: middle; height: 1em; border: 0;">',
cross: '<img src="//upload.wikimedia.org/wikipedia/commons/a/a2/X_mark.svg" style="vertical-align: middle; height: 1em; border: 0;">'
},


// Collect DOM elements
createPreferencesPage: function() {
var $heading = $('.mw-first-heading');
var $body = $('.mw-body-content');
if (!$heading.length || !$body.length) {
mw.notify(this.getMessage('config-notloaded'));
return;
}
$heading.text(this.getMessage('config-heading'));


// Config container
document.title = 'MarkBLockedPreferences - Wikipedia';
var $container = $('<div>').prop('id', 'mblc-container');
$body.empty().append($container);


// Transparent overlay of the container used to make elements in it unclickable
var container = document.createElement('div');
container.id = 'gmblp-container';
var $overlay = $('<div>').prop('id', 'mblc-container-overlay').hide();
$container.after($overlay);


// Option container fieldset
/**
var fieldset = new OO.ui.FieldsetLayout({
* @param {HTMLElement} appendTo
id: 'mblc-optionfield',
* @param {string} id
label: this.getMessage('config-label-fieldset')
* @param {string} labelText
});
* @param {boolean} [appendBr]
$container.append(fieldset.$element);
* @returns {HTMLInputElement} checkbox
*/
var createCheckbox = function(appendTo, id, labelText, appendBr) {
var checkbox = document.createElement('input');
appendTo.appendChild(checkbox);
checkbox.type = 'checkbox';
checkbox.id = id;
checkbox.style.marginRight = '0.5em';
var belowHyphen = id.replace(/^[^-]+-/, '');
if (MarkBLocked.prefs[belowHyphen]) checkbox.checked = MarkBLocked.prefs[belowHyphen];
var label = document.createElement('label');
appendTo.appendChild(label);
label.htmlFor = id;
label.appendChild(document.createTextNode(labelText));
if (appendBr) appendTo.appendChild(document.createElement('br'));
return checkbox;
};


// Options
var bodyDiv = document.createElement('div');
var localIps = new OO.ui.CheckboxInputWidget({
container.appendChild(bodyDiv);
selected: this.options.localips
bodyDiv.id = 'gmblp-body';
});
var localips = createCheckbox(bodyDiv, 'gmblp-localips', MarkBLocked.configpage.check.localips, true);
var globalUsers = new OO.ui.CheckboxInputWidget({
var globalusers = createCheckbox(bodyDiv, 'gmblp-globalusers', MarkBLocked.configpage.check.globalusers, true);
selected: this.options.globalusers
var globalips = createCheckbox(bodyDiv, 'gmblp-globalips', MarkBLocked.configpage.check.globalips, true);
});
var globalIps = new OO.ui.CheckboxInputWidget({
selected: this.options.globalips
});
fieldset.addItems([
new OO.ui.FieldLayout(localIps, {
label: this.getMessage('config-label-localips'),
align: 'inline'
}),
new OO.ui.FieldLayout(globalUsers, {
label: this.getMessage('config-label-globalusers'),
align: 'inline'
}),
new OO.ui.FieldLayout(globalIps, {
label: this.getMessage('config-label-globalips'),
align: 'inline'
})
]);


// Save button
var saveBtn = document.createElement('input');
var saveButton = new OO.ui.ButtonWidget({
bodyDiv.appendChild(saveBtn);
saveBtn.id = 'gmblp-save';
id: 'mblc-save',
label: this.getMessage('config-label-save'),
saveBtn.type = 'button';
icon: 'bookmarkOutline',
saveBtn.style.marginTop = '1em';
flags: ['primary', 'progressive']
saveBtn.value = MarkBLocked.configpage.save.button;
});
$container.append(saveButton.$element);
var _this = this;
saveButton.$element.off('click').on('click', function() {


$overlay.show();
/**
* @param {HTMLElement} appendTo
* @param {string} id
* @returns {HTMLParagraphElement}
*/
var createHiddenP = function(appendTo, id) {
var p = document.createElement('p');
appendTo.appendChild(p);
p.id = id;
p.style.display = 'none';
return p;
};


// Change the save button's label
var status = createHiddenP(bodyDiv, 'gmblp-status');
var $img = $('<img>')
var lastsaved = createHiddenP(bodyDiv, 'gmblp-lastsaved');
.prop('src', '//upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif')
.css({
verticalAlign: 'middle',
height: '1em',
border: '0',
marginRight: '1em'
});
var $label = $('<span>').append($img);
var textNode = document.createTextNode(_this.getMessage('config-label-saving'));
$label.append(textNode);
saveButton.setIcon(null).setLabel($label);


// Get config
// Replace body content. Easier to just replace mw.util.$content[0].innerHTML, but this would remove #p-cactions etc.
var /** @type {UserOptions} */ cfg = {
var bodyContent = document.querySelector('.mw-body-content') || mw.util.$content[0];
localips: localIps.isSelected(),
bodyContent.replaceChildren(container);
globalusers: globalUsers.isSelected(),
var firstHeading = document.querySelector('.mw-first-heading');
globalips: globalIps.isSelected()
if (firstHeading) { // The innerHTML of .mw-body-content was replaced
};
firstHeading.textContent = MarkBLocked.configpage.heading;
var strCfg = JSON.stringify(cfg);
} else { // The innerHTML of mw.util.$content[0] was replaced (in this case the heading is gone)
var h1 = document.createElement('h1');
h1.textContent = MarkBLocked.configpage.heading;
container.prepend(h1);
}


// Save config
/** @param {boolean} disable */
api.saveOption(_this.optionKey, strCfg)
var toggleDisabled = function(disable) {
.then(function() {
[localips, globalusers, globalips, saveBtn].forEach(function(el) {
mw.user.options.set(_this.optionKey, strCfg);
el.disabled = disable;
return null;
});
})
};
.catch(/** @param {string} code */ function(code, err) {
console.warn(err);
return code;
})
.then(/** @param {string?} err */ function(err) {
if (err) {
mw.notify(_this.getMessage('config-label-savefailed') + '(' + err + ')', {type: 'error'});
} else {
mw.notify(_this.getMessage('config-label-savedone'), {type: 'success'});
}
saveButton.setIcon('bookmarkOutline').setLabel(_this.getMessage('config-label-save'));
$overlay.hide();
});


});
var msgTimeout;
saveBtn.addEventListener('click', function() {


};
clearTimeout(msgTimeout);
toggleDisabled(true);
status.style.display = 'block';
status.innerHTML = MarkBLocked.configpage.save.doing + ' ' + MarkBLocked.images.loading;


/**
$.extend(MarkBLocked.prefs, {
* Create a portlet link to the config page.
localips: localips.checked,
* @returns {void}
globalusers: globalusers.checked,
* @requires mediawiki.util
globalips: globalips.checked
*/
});
MarkBLocked.prototype.createPortletLink = function() {
var newPrefsStr = JSON.stringify(MarkBLocked.prefs);
var portlet = mw.util.addPortletLink(
'p-tb',
mw.util.getUrl('Special:MarkBLockedConfig'),
this.getMessage('portlet-text'),
'ca-mblc'
);
if (!portlet) {
console.error('Failed to create a portlet link for MarkBLocked.');
}
};


/**
// API call to save the preferences
* Mark up user links.
api.saveOption(MarkBLocked.saveOptionName, newPrefsStr)
* @param {JQuery<HTMLElement>} $content
.then(function() { // Success
* @returns {void}
* @requires mediawiki.util
* @requires mediawiki.api
*/
MarkBLocked.prototype.markup = function($content) {


var collected = this.collectLinks($content);
status.innerHTML = MarkBLocked.configpage.save.done + ' ' + MarkBLocked.images.check;
var userLinks = collected.userLinks;
lastsaved.style.display = 'block';
if ($.isEmptyObject(userLinks)) {
lastsaved.textContent = MarkBLocked.configpage.save.lastsave + ' ' + new Date().toJSON().split('.')[0];
console.log('MarkBLocked', {
mw.user.options.set(MarkBLocked.saveOptionName, newPrefsStr);
links: 0
});
return;
}
var users = collected.users;
var ips = collected.ips;
var allUsers = users.concat(ips);
var options = this.options;


this.markBlockedUsers(userLinks, allUsers).then(function(markedUsers) {
}).catch(function(code, err) { // Failure


if (markedUsers === null) { // Aborted
mw.log.error(err);
return;
status.innerHTML = MarkBLocked.configpage.save.failed + ' ' + MarkBLocked.images.cross;
} else {
console.log('MarkBLocked', {
links: $('.mbl-userlink').length,
user_registered: users.length,
user_anonymous: ips.length
});
}


// Create a batch array for additional markups
}).then(function() {
var ipsThatMightBeBlocked = ips.filter(function(ip) {
toggleDisabled(false);
return markedUsers.indexOf(ip) === -1;
msgTimeout = setTimeout(function() { // Hide the progress message after 3.5 seconds
});
status.style.display = 'none';
var /** @type {BatchObject[]} */ batchArray = [];
status.innerHTML = '';
if (options.localips && ipsThatMightBeBlocked.length) {
}, 3500);
ipsThatMightBeBlocked.forEach(function(ip) {
});
batchArray.push({
params: {
action: 'query',
list: 'blocks',
bklimit: 1,
bkip: ip,
bkprop: 'user|expiry|restrictions',
formatversion: '2'
},
callback: function(res) {
var /** @type {ApiResponseQueryListBlocks[]=} */ resBlk = res && res.query && res.query.blocks;
var /** @type {ApiResponseQueryListBlocks=} */ resObj;
if (resBlk && (resObj = resBlk[0])) {
var partialBlk = resObj.restrictions && !Array.isArray(resObj.restrictions);
var clss;
if (/^in/.test(resObj.expiry)) {
clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-indef';
} else {
clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-temp';
}
addClass(userLinks, ip, clss);
}
}
});
});
}
if (options.globalusers && users.length) {
users.forEach(function(user) {
batchArray.push({
params: {
action: 'query',
list: 'globalallusers',
agulimit: 1,
agufrom: user,
aguto: user,
aguprop: 'lockinfo',
formatversion: '2'
},
callback: function(res) {
/** @typedef {{locked?: string;}} ApiResponseQueryListGlobalallusers */
var /** @type {ApiResponseQueryListGlobalallusers[]=} */ resLck = res && res.query && res.query.globalallusers;
var /** @type {ApiResponseQueryListGlobalallusers=} */ resObj;
if (resLck && (resObj = resLck[0]) && resObj.locked === '') {
addClass(userLinks, user, 'mbl-globally-locked');
}
}
});
});
}
if (options.globalips && ips.length) {
ips.forEach(function(ip) {
batchArray.push({
params: {
action: 'query',
list: 'globalblocks',
bgip: ip,
bglimit: 1,
bgprop: 'address|expiry',
formatversion: '2'
},
callback: function(res) {
/** @typedef {{expiry: string;}} ApiResponseQueryListGlobalblocks */
var /** @type {ApiResponseQueryListGlobalblocks[]=} */ resGblk = res && res.query && res.query.globalblocks;
var /** @type {ApiResponseQueryListGlobalblocks=} */ resObj;
if (resGblk && (resObj = resGblk[0])) {
var clss = /^in/.test(resObj.expiry) ? 'mbl-globally-blocked-indef' : 'mbl-globally-blocked-temp';
addClass(userLinks, ip, clss);
}
}
});
});
}


if (batchArray.length) {
});
batchRequest(batchArray);
}


});
},


};
/**
* @type {{article: RegExp, script: RegExp, user: RegExp}}
* @private
*/
// @ts-ignore
_regex: {},


/**
/**
* Object that stores collected user links, keyed by usernames and valued by an array of anchors.
* @returns {{article: RegExp, script: RegExp, user: RegExp}}
* @typedef {Object.<string, HTMLAnchorElement[]>} UserLinks
*/
*/
getRegex: function() {
/**
if ($.isEmptyObject(MarkBLocked._regex)) {
* Collect user links to mark up.
var user = '(?:' + MarkBLocked.getAliases([2, 3], '|') + '):';
* @param {JQuery<HTMLElement>} $content
var contribs_CA = MarkBLocked.contribs_CA.length === 0 ? '' : '|' + MarkBLocked.contribs_CA.join('|');
* @returns {{userLinks: UserLinks; users: string[]; ips: string[];}}
contribs_CA = '(?:' + MarkBLocked.getAliases([-1], '|') + '):(?:contrib(?:ution)?s|ca|centralauth' + contribs_CA + ')/';
* @requires mediawiki.util
MarkBLocked._regex = {
*/
article: new RegExp(mw.config.get('wgArticlePath').replace('$1', '([^#?]+)')), // '/wiki/PAGENAME'
MarkBLocked.prototype.collectLinks = function($content) {
script: new RegExp(mw.config.get('wgScript') + '\\?title=([^#&]+)'), // '/w/index.php?title=PAGENAME'
user: new RegExp('^(?:' + user + '|' + contribs_CA + ')([^/#]+|[a-f\\d:\\.]+/\\d\\d)$', 'i')
};
}
return MarkBLocked._regex;
},


// Get all anchors in the page content
/**
var $anchors = $content.find('a');
* @type {Object.<string, Array<HTMLAnchorElement>>} {'username': [\<link1>, \<link2>, ...], 'username2': [\<link3>, \<link4>, ...], ...}
var $pNamespaces = $('#p-associated-pages');
*/
if ($pNamespaces.length && !$content.find($pNamespaces).length) { // Add links in left navigation
userLinks: {},
$anchors = $anchors.add($pNamespaces.find('a'));
}


// Set up variables
collectUserLinks: function() {
var _this = this;
var /** @type {string[]} */ users = [];
var /** @type {string[]} */ ips = [];
var ignoredClassesPr = /\b(mw-rollback-|autocomment)/;
var /** @type {UserLinks} */ userLinks = {};


// Filter out user links
/** @type {Array<HTMLAnchorElement>} */
$anchors.each(function(_, a) {
var anchors = Array.prototype.slice.call(mw.util.$content[0].getElementsByTagName('a'));


// Ignore some anchors
// Additional anchors outside the content body
var href = a.href;
var contribsToolLinks = document.querySelector('.mw-contributions-user-tools');
var pr = a.parentElement;
var pNamespaces = document.getElementById('p-namespaces');
if (
[contribsToolLinks, pNamespaces].forEach(function(wrapper) {
!href ||
if (!wrapper) return;
href[0] === '#' ||
anchors = anchors.concat(Array.prototype.slice.call(wrapper.getElementsByTagName('a')));
mw.util.getParamValue('action', href) && !mw.util.getParamValue('redlink', href) ||
});
mw.util.getParamValue('diff', href) ||
if (!anchors.length) return;
mw.util.getParamValue('oldid', href) ||
a.type === 'button' ||
a.role === 'button' ||
pr && ignoredClassesPr.test(pr.className)
) {
return;
}


// Get the associated pagetitle
var regex = MarkBLocked.getRegex();
var /** @type {RegExpExecArray?} */ m,
/** @type {string} */ pagetitle;
if ((m = _this.regex.article.exec(href))) {
pagetitle = m[1];
} else if ((m = _this.regex.script.exec(href))) {
pagetitle = m[1];
} else {
return;
}
pagetitle = decodeURIComponent(pagetitle).replace(/ /g, '_');


// Extract a username from the pagetitle
/** @type {Array<string>} */
m = _this.regex.user.exec(pagetitle);
var users = [];
if (!m) {
/** @type {Array<string>} */
return;
var ips = [];
}
var ignoredClasses = /\bmw-changeslist-/;
var username = m[1].replace(/_/g, ' ').trim();
var ignoredClassesPr = /\bmw-(history|rollback)-|\bautocomment/;
var /** @type {string[]} */ arr;
if (mw.util.isIPAddress(username, true)) {
// @ts-ignore
username = mw.util.sanitizeIP(username) || username; // The right operand is never reached
arr = ips;
} else if (/[/@#<>[\]|{}:]|^(\d{1,3}\.){3}\d{1,3}$/.test(username)) {
// Ensure the username doesn't contain characters that can't be used for usernames (do this here or block status query might fail)
console.log('MarkBLocked: Unprocessable username: ' + username);
return;
} else {
arr = users;
if (!/^[\u10A0-\u10FF]/.test(username)) { // ucFirst, except for Georgean letters
username = username.charAt(0).toUpperCase() + username.slice(1);
}
}
if (arr.indexOf(username) === -1) {
arr.push(username);
}


a.classList.add('mbl-userlink');
anchors.forEach(function(a) {
if (userLinks[username]) {
userLinks[username].push(a);
} else {
userLinks[username] = [a];
}


});
if (a.type === 'button') return;
if (a.role === 'button') return;


return {
// Ignore some anchors
userLinks: userLinks,
var pr, pr2;
users: users,
if (ignoredClasses.test(a.className) ||
ips: ips
(pr = a.parentElement) && ignoredClassesPr.test(pr.className) ||
};
// cur/prev revision links
pr && (pr2 = pr.parentElement) && pr2.classList.contains('mw-history-histlinks') && pr2.classList.contains('mw-changeslist-links')
) {
return;
}


};
var href = a.href;
if (!href) return;
if (href[0] === '#') return;


/**
var m, pagetitle;
* @typedef ApiResponseQueryListBlocks
if ((m = regex.article.exec(href))) {
* @type {object}
pagetitle = m[1];
* @property {[]|{}} [restrictions]
} else if ((m = regex.script.exec(href))) {
* @property {string} expiry
pagetitle = m[1];
* @property {string} user
} else {
*/
return;
/**
}
* Mark up locally blocked registered users and single IPs (this can't detect single IPs included in blocked IP ranges)
pagetitle = decodeURIComponent(pagetitle).replace(/ /g, '_');
* @param {UserLinks} userLinks
* @param {string[]} usersArr
* @returns {JQueryPromise<string[]?>} Usernames whose links are marked up (`null` if aborted).
* @requires mediawiki.api
*/
MarkBLocked.prototype.markBlockedUsers = function(userLinks, usersArr) {


if (!usersArr.length) {
// Extract a username from the page title
return $.Deferred().resolve([]);
if (!(m = regex.user.exec(pagetitle))) return;
} else {
var username = m[1].replace(/_/g, ' ');
usersArr = usersArr.slice(); // Deep copy
if (mw.util.isIPAddress(username, true)) {
}
username = username.toUpperCase(); // IPv6 addresses are case-insensitive
if (ips.indexOf(username) === -1) ips.push(username);
} else {
// Ensure the username doesn't contain characters that can't be used for usernames (do this here or block status query might fail)
if (/[/@#<>[\]|{}:]|^(\d{1,3}\.){3}\d{1,3}$/.test(username)) {
return;
} else {
username = username.slice(0, 1).toUpperCase() + username.slice(1); // Capitalize 1st letter: required for links like [[Special:Contribs/user]]
if (users.indexOf(username) === -1) users.push(username);
}
}


var /** @type {string[]} */ marked = [];
// Add a class to this anchor and save the anchor into an array
var aborted = false;
a.classList.add('gmbl-userlink');
/**
if (!MarkBLocked.userLinks[username]) {
* @param {string[]} users
MarkBLocked.userLinks[username] = [a];
* @returns {JQueryPromise<void>}
} else {
*/
MarkBLocked.userLinks[username].push(a);
var req = function(users) {
}
return api.post({ // This MUST be a POST request because the parameters can exceed the word count limit of URI
action: 'query',
list: 'blocks',
bklimit: 'max',
bkusers: users.join('|'),
bkprop: 'user|expiry|restrictions',
formatversion: '2'
}).then(function(res){
var /** @type {ApiResponseQueryListBlocks[]=} */ resBlk = res && res.query && res.query.blocks;
if (resBlk) {
resBlk.forEach(function(obj) {
var partialBlk = obj.restrictions && !Array.isArray(obj.restrictions); // Boolean: True if partial block
var clss;
if (/^in/.test(obj.expiry)) {
clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-indef';
} else {
clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-temp';
}
var markedUser = addClass(userLinks, obj.user, clss);
if (markedUser) {
marked.push(markedUser);
}
});
}
return void 0;
}).catch(function(_, err) {
// @ts-ignore
if (err.exception === 'abort') {
aborted = true;
} else {
console.error(err);
}
return void 0;
});
};


// API calls
});
var /** @type {JQueryPromise<void>[]} */ deferreds = [];
if ($.isEmptyObject(MarkBLocked.userLinks)) return;
while (usersArr.length) {
deferreds.push(req(usersArr.splice(0, this.apilimit)));
}
return $.when.apply($, deferreds).then(function() {
return aborted ? null : marked;
});


};
// Check (b)lock status and do markup if needed
var allUsers = users.concat(ips);
MarkBLocked.markBlockedUsers(allUsers);
if (MarkBLocked.prefs.localips) MarkBLocked.markIpsInBlockedRanges(ips);
if (MarkBLocked.prefs.globalusers) MarkBLocked.markLockedUsers(users);
if (MarkBLocked.prefs.globalips) MarkBLocked.markGloballyBlockedIps(ips);


/**
},
* Add a class to all anchors associated with a certain username.
* @param {UserLinks} userLinks
* @param {string} userName
* @param {string} className
* @returns {string?} The username if any link is marked up, or else `null`.
*/
function addClass(userLinks, userName, className) {
var links = userLinks[userName]; // Get all links related to the user
if (links) {
for (var i = 0; links && i < links.length; i++) {
links[i].classList.add(className);
}
return userName;
} else {
console.error('MarkBLocked: There\'s no link for User:' + userName);
return null;
}
}


/**
/**
* @typedef {Object.<string, any>} DynamicObject
* Add a class to all anchors associated with a certain username
*/
* @param {string} userName
/**
* @param {string} className
* @typedef BatchObject
*/
* @type {object}
addClass: function(userName, className) {
* @property {DynamicObject} params
var links = MarkBLocked.userLinks[userName]; // Get all links related to the user
* @property {(res?: DynamicObject) => void} callback
for (var i = 0; links && i < links.length; i++) {
*/
links[i].classList.add(className);
/**
}
* Send batched API requests.
},
*
* MarkBLocked has to send quite a few API requests when additional markup functionalities are enabled,
* and this can lead to an `net::ERR_INSUFFICIENT_RESOURCES` error if too many requests are sent all
* at once. This (private) function sends API requests by creating batches of 1000, where each batch is
* processed sequentially after the older batch is resolved.
* @param {BatchObject[]} batchArray
* @returns {JQueryPromise<void>}
* @requires mediawiki.api
*/
function batchRequest(batchArray) {


// Unflatten the array of objects to an array of arrays of objects
/**
var unflattened = batchArray.reduce(/** @param {BatchObject[][]} acc */ function(acc, obj) {
* Mark up locally blocked registered users and single IPs (this can't detect single IPs included in blocked IP ranges)
var len = acc.length - 1;
* @param {Array<string>} usersArr
if (Array.isArray(acc[len]) && acc[len].length < 1000) {
*/
acc[len].push(obj);
markBlockedUsers: function(usersArr) {
} else {
acc[len + 1] = [obj];
}
return acc;
}, [[]]);


var aborted = false;
usersArr = usersArr.slice(); // Deep copy just in case; this array will be spliced (not quite needed actually)
/**
var bklimit = MarkBLocked.hasApiHighlimits ? 500 : 50; // Better performance for users with 'apihighlimits'
* Send an API request.
* @param {BatchObject} batchObj
* @returns {JQueryPromise<void>}
*/
var req = function(batchObj) {
return api.get(batchObj.params)
.then(batchObj.callback)
.catch(function(_, err) {
// @ts-ignore
if (err.exception === 'abort') {
aborted = true;
} else {
console.error(err);
}
return void 0;
});
};
/**
* Send batched API requests.
* @param {number} index
* @returns {JQueryPromise<void>}
*/
var batch = function(index) {
var batchElementArray = unflattened[index];
var /** @type {JQueryPromise<void>[]} */ deferreds = [];
batchElementArray.forEach(function(batchObj) {
deferreds.push(req(batchObj));
});
return $.when.apply($, deferreds).then(function() {
console.log('MarkBLocked batch count: ' + deferreds.length);
index++;
if (!aborted && unflattened[index]) {
return batch(index);
} else {
return void 0;
}
});
};


return batch(0);
/**
* @param {Array<string>} arr
*/
var query = function(arr) {
api.post({ // This MUST be a POST request because the parameters can exceed the word count limit of URI
action: 'query',
list: 'blocks',
bklimit: bklimit,
bkusers: arr.join('|'),
bkprop: 'user|expiry|restrictions',
formatversion: '2'
}).then(function(res){


}
var resBlk;
if (!res || !res.query || !(resBlk = res.query.blocks) || !resBlk.length) return;


return MarkBLocked;
resBlk.forEach(function(obj) {
var partialBlk = obj.restrictions && !Array.isArray(obj.restrictions); // Boolean: True if partial block
var clss;
if (/^in/.test(obj.expiry)) {
clss = partialBlk ? 'gmbl-blocked-partial' : 'gmbl-blocked-indef';
} else {
clss = partialBlk ? 'gmbl-blocked-partial' : 'gmbl-blocked-temp';
}
MarkBLocked.addClass(obj.user, clss);
});


})();
}).catch(function(code, err) {
mw.log.error(err);
});
};

// API calls
while (usersArr.length) {
query(usersArr.splice(0, bklimit));
}

},

/**
* Mark up all locally blocked IPs including single IPs in blocked IP ranges
* @param {Array<string>} ipsArr
*/
markIpsInBlockedRanges: function(ipsArr) {

/**
* @param {string} ip
*/
var query = function(ip) {
api.get({
action: 'query',
list: 'blocks',
bklimit: '1', // Only one IP can be checked in one API call, which means it's neccesary to send as many API requests as the
bkip: ip, // length of the array. You can see why we need the personal preferences: This can lead to performance issues.
bkprop: 'user|expiry|restrictions',
formatversion: '2'
}).then(function(res){

var resBlk;
if (!res || !res.query || !(resBlk = res.query.blocks) || !resBlk.length) return;

resBlk = resBlk[0];
var partialBlk = resBlk.restrictions && !Array.isArray(resBlk.restrictions);
var clss;
if (/^in/.test(resBlk.expiry)) {
clss = partialBlk ? 'gmbl-blocked-partial' : 'gmbl-blocked-indef';
} else {
clss = partialBlk ? 'gmbl-blocked-partial' : 'gmbl-blocked-temp';
}
MarkBLocked.addClass(ip, clss);

}).catch(function(code, err) {
mw.log.error(err);
});
};

// API calls
ipsArr.forEach(query);

},

/**
* Mark up globally locked users
* @param {Array<string>} regUsersArr
*/
markLockedUsers: function(regUsersArr) {

/**
* @param {string} regUser
*/
var query = function(regUser) {
api.get({
action: 'query',
list: 'globalallusers',
agulimit: '1',
agufrom: regUser,
aguto: regUser,
aguprop: 'lockinfo',
formatversion: '2'
}).then(function(res) {

var resLck;
if (!res || !res.query || !(resLck = res.query.globalallusers) || !resLck.length) return;

var locked = resLck[0].locked === '';
if (locked) MarkBLocked.addClass(regUser, 'gmbl-globally-locked');

}).catch(function(code, err) {
mw.log.error(err);
});
};

// API calls
regUsersArr.forEach(query);

},

/**
* Mark up (all) globally blocked IPs
* @param {Array} ipsArr
*/
markGloballyBlockedIps: function(ipsArr) {

/**
* @param {string} ip
*/
var query = function(ip) {
api.get({
action: 'query',
list: 'globalblocks',
bgip: ip,
bglimit: '1',
bgprop: 'address|expiry',
formatversion: '2'
}).then(function(res){

var resBlk;
if (!res || !res.query || !(resBlk = res.query.globalblocks) || !resBlk.length) return;

resBlk = resBlk[0];
var clss = /^in/.test(resBlk.expiry) ? 'gmbl-globally-blocked-indef' : 'gmbl-globally-blocked-temp';
MarkBLocked.addClass(ip, clss);

}).catch(function(code, err) {
mw.log.error(err);
});
};

// API calls
ipsArr.forEach(query);

}

};

$.when(mw.loader.using(['mediawiki.util', 'mediawiki.api', 'mediawiki.user']), $.ready).then(MarkBLocked.init);

// *******************************************************************************************************************

// @ts-ignore "Cannot find name 'mediaWiki'."
})(mediaWiki, jQuery);
//</nowiki>
//</nowiki>

2023年11月15日 (水) 05:12時点における版

// @ts-check
/* eslint-disable @typescript-eslint/no-this-alias */
/* global mw, OO */
//<nowiki>
module.exports = /** @class */ (function() {
// var MarkBLocked = /** @class */ (function() {

	/** @readonly */
	var defaultOptionKey = 'userjs-markblocked-config';

	/**
	 * @typedef UserOptions
	 * @type {object}
	 * @property {boolean} localips
	 * @property {boolean} globalusers
	 * @property {boolean} globalips
	 */
	/**
	 * @typedef ConstructorConfig
	 * @type {object}
	 * @property {string} [optionKey]  The key of `mw.user.options`, defaulted to `userjs-markblocked-config`.
	 * @property {Object.<string, Lang>} [i18n] A language object to merge to {@link MarkBLocked.i18n}. Using this config makes
	 * it possible to configure the default interface messages and add a new interface language (for the latter, a value needs
	 * to be passed to the {@link lang} parameter.)
	 * @property {string} [lang] The code of the language to use in the interface messages, defaulted to `en`.
	 * @property {string[]} [contribs_CA] Special page aliases for Contributions and CentralAuth in the local language (no need
	 * to pass `Contributions`, `Contribs`, `CentralAuth`, `CA`, and  `GlobalAccount`). If not provided, aliases are fetched from
	 * the API.
	 * @property {string[]} [groupsAHL] Local user groups with the `apihighlimits` user right, defaulted to `['sysop', 'bot']`;
	 */
	/**
	 * Initialize the properties of the `MarkBLocked` class. This is only to be called by `MarkBLocked.init`.
	 * @param {ConstructorConfig} [config]
	 * @constructor
	 * @requires mw.user
	 */
	function MarkBLocked(config) {

		var cfg = config || {};

		// User options
		var defaultOptions = {
			localips: false,
			globalusers: true,
			globalips: false
		};
		/**
		 * The key of `mw.user.options`.
		 * @readonly
		 */
		this.optionKey = cfg.optionKey || defaultOptionKey;
		var /** @type {string} */ optionsStr = mw.user.options.get(this.optionKey) || '{}';
		var /** @type {UserOptions} */ options;
		try {
			options = JSON.parse(optionsStr);
		}
		catch(err) {
			console.error(err);
			options = defaultOptions;
		}
		/** @type {UserOptions} */
		this.options = $.extend(defaultOptions, options);

		// Language options
		if (cfg.i18n) {
			$.extend(MarkBLocked.i18n, cfg.i18n);
		}
		var langCode = 'en';
		if (cfg.lang) {
			if (Object.keys(MarkBLocked.i18n).indexOf(cfg.lang) !== -1) {
				langCode = cfg.lang;
			} else {
				console.error('"' + cfg.lang  + '" is not available as the interface language of MarkBLocked.');
			}
		}
		/** @type {Lang} */
		this.msg = MarkBLocked.i18n[langCode];

		// Regex to collect user links
		var wgNamespaceIds = mw.config.get('wgNamespaceIds'); // {"special": -1, "user": 2, ...}
		var /** @type {string[]} */ specialAliases = [];
		var /** @type {string[]} */ userAliases = [];
		for (var alias in wgNamespaceIds) {
			var namespaceId = wgNamespaceIds[alias];
			switch(namespaceId) {
				case -1:
					specialAliases.push(alias);
					break;
				case 2:
				case 3:
					userAliases.push(alias);
					break;
				default:
			}
		}
		var rContribsCA = cfg.contribs_CA && cfg.contribs_CA.length ? '|' + cfg.contribs_CA.join('|') : '';
		rContribsCA = '(?:' + specialAliases.join('|') + '):(?:contrib(?:ution)?s|ca|centralauth|globalaccount' + rContribsCA + ')/';
		var rUser = '(?:' + userAliases.join('|') + '):';
		/**
		 * Regular expressions to collect user links.
		 * @typedef LinkRegex
		 * @type {object}
		 * @property {RegExp} article `/wiki/PAGENAME`: $1: PAGENAME
		 * @property {RegExp} script `/w/index.php?title=PAGENAME`: $1: PAGENAME
		 * @property {RegExp} user `User:(USERNAME|CIDR)`: $1: USERNAME or CIDR
		 */
		/** @type {LinkRegex} */
		this.regex = {
			article: new RegExp(mw.config.get('wgArticlePath').replace('$1', '([^#?]+)')),
			script: new RegExp(mw.config.get('wgScript') + '\\?title=([^#&]+)'),
			user: new RegExp('^(?:' + rContribsCA + '|' + rUser + ')([^/#]+|[a-f\\d:\\.]+/\\d\\d)$', 'i')
		};

		// Validate apihighlimits
		var groupsAHLLocal = cfg.groupsAHL || ['sysop', 'bot'];
		var groupsAHLGlobal = [
			'apihighlimits-requestor',
			'founder',
			'global-bot',
			// 'global-sysop',
			'staff',
			'steward',
			'sysadmin',
			'wmf-researcher'
		];
		var groupsAHL = groupsAHLLocal.concat(groupsAHLGlobal);
		// @ts-ignore
		var hasAHL = mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups') || []).some(function(group) {
			return groupsAHL.indexOf(group) !== -1;
		});
		/**
		 * The maximum number of batch parameter values for the API.
		 * @type {500|50}
		 */
		this.apilimit = hasAHL ? 500 : 50;

	}

	/**
	 * @typedef Lang
	 * @type {object}
	 * @property {string} config-notloaded
	 * @property {string} config-heading
	 * @property {string} config-label-fieldset
	 * @property {string} config-label-localips
	 * @property {string} config-label-globalusers
	 * @property {string} config-label-globalips
	 * @property {string} config-label-save
	 * @property {string} config-label-saving
	 * @property {string} config-label-savedone
	 * @property {string} config-label-savefailed
	 * @property {string} portlet-text
	 */
	/**
	 * @type {Object.<string, Lang>}
	 * @static
	 */
	MarkBLocked.i18n = {
		en: {
			'config-notloaded': 'Failed to load the interface.',
			'config-heading': 'Configure MarkBLocked',
			'config-label-fieldset': 'Markup settings',
			'config-label-localips': 'Mark up IPs in locally blocked IP ranges',
			'config-label-globalusers': 'Mark up globally locked users',
			'config-label-globalips': 'Mark up globally blocked IPs',
			'config-label-save': 'Save settings',
			'config-label-saving': 'Saving settings...',
			'config-label-savedone': 'Sucessfully saved the settings.',
			'config-label-savefailed': 'Failed to save the settings. ',
			'portlet-text': 'Configure MarkBLocked'
		},
		ja: {
			'config-notloaded': 'インターフェースの読み込みに失敗しました。',
			'config-heading': 'MarkBLockedの設定',
			'config-label-fieldset': 'マークアップ設定',
			'config-label-localips': 'ブロックされたIPレンジに含まれるIPをマークアップ',
			'config-label-globalusers': 'グローバルロックされた利用者をマークアップ',
			'config-label-globalips': 'グローバルブロックされたIPをマークアップ',
			'config-label-save': '設定を保存',
			'config-label-saving': '設定を保存中...',
			'config-label-savedone': '設定の保存に成功しました。',
			'config-label-savefailed': '設定の保存に失敗しました。',
			'portlet-text': 'MarkBLockedの設定'
		}
	};

	/**
	 * Get an interface message of MarkBLocked.
	 * @param {keyof Lang} key
	 * @returns {string}
	 */
	MarkBLocked.prototype.getMessage = function(key) {
		return this.msg[key];
	};

	/**
	 * @type {mw.Api}
	 * @readonly
	 */
	var api;
	/**
	 * Initialize `MarkBLocked`.
	 * @param {ConstructorConfig} [config]
	 * @returns {JQueryPromise<MarkBLocked>}
	 * @static
	 */
	MarkBLocked.init = function(config) {

		var cfg = config || {};

		// Wait for dependent modules and the DOM to get ready
		var modules = [
			'mediawiki.user',
			'mediawiki.api',
			'mediawiki.util',
			'oojs-ui',
			'oojs-ui.styles.icons-moderation',
		];
		var onConfig = mw.config.get('wgNamespaceNumber') === -1 && /^(markblockedconfig|mblc)$/i.test(mw.config.get('wgTitle'));
		if (!onConfig) {
			modules.splice(3);
		}
		return $.when(
			mw.loader.using(modules),
			$.ready
		).then(function() { // When ready

			api = new mw.Api();

			// For backwards compatibility, clear old config if any
			var oldOptionKey = 'userjs-gmbl-preferences';
			var /** @type {string?} */ oldCfgStr = mw.user.options.get(oldOptionKey);
			var /** @type {JQueryPromise<void>} */ backwards;
			if (oldCfgStr && (cfg.optionKey === void 0 || cfg.optionKey === defaultOptionKey) && !mw.user.options.get(defaultOptionKey)) {
				var /** @type {Record<string, string?>} */ params = {};
				params[oldOptionKey] = null;
				params[defaultOptionKey] = oldCfgStr;
				backwards = api.saveOptions(params).then(function() {
					mw.user.options.set(oldOptionKey, null);
					mw.user.options.set(defaultOptionKey, oldCfgStr);
				});
			} else {
				backwards = $.Deferred().resolve();
			}

			// Entry point
			var /** @type {JQueryPromise<string[]?>} */ ccaDeferred =
				onConfig ?
				$.Deferred().resolve([]) :
				cfg.contribs_CA ?
				$.Deferred().resolve(cfg.contribs_CA) :
				MarkBLocked.getContribsCA();
			return $.when(ccaDeferred, backwards).then(function(contribs_CA) {

				if (contribs_CA) {
					cfg.contribs_CA = contribs_CA;
				} else {
					console.warn('MarkBLocked: Failed to get special page aliases.');
					cfg.contribs_CA = [];
				}

				var MBL = new MarkBLocked(cfg);
				if (onConfig) {
					MBL.createConfigInterface();
				} else {
					MBL.createPortletLink();
					var /** @type {NodeJS.Timeout} */ hookTimeout;
					mw.hook('wikipage.content').add(/** @param {JQuery<HTMLElement>} $content */ function($content) {
						clearTimeout(hookTimeout); // Prevent hook from being triggered multiple times
						hookTimeout = setTimeout(function() {
							api.abort(); // Prevent the old HTTP requests from being taken over to the new markup procedure
							MBL.markup($content);
						}, 100);
					});
				}
				return MBL;

			});

		});

	};

	/**
	 * Get special page aliases for `Contributions` and `CentralAuth`.
	 * @returns {JQueryPromise<string[]?>}
	 * @requires mediawiki.api
	 * @static
	 */
	MarkBLocked.getContribsCA = function() {
		return api.get({
			action: 'query',
			meta: 'siteinfo',
			siprop: 'specialpagealiases',
			formatversion: '2'
		}).then(function(res) {
			var resSpa = res && res.query && res.query.specialpagealiases;
			if (Array.isArray(resSpa)) {
				return resSpa.reduce(
					/**
					 * @param {string[]} acc
					 * @param {{realname: string; aliases: string[];}} obj
					 * @returns
					 */
					function(acc, obj) {
						var /** @type {string[]} */ exclude = [];
						switch(obj.realname) {
							case 'Contributions':
								exclude = ['Contributions', 'Contribs'];
								break;
							case 'CentralAuth':
								exclude = ['CentralAuth', 'CA', 'GlobalAccount'];
						}
						if (exclude.length) {
							var aliases = obj.aliases.filter(function(alias) {
								return exclude.indexOf(alias) === -1;
							});
							acc.concat(aliases);
						}
						return acc;
					},
					[]
				);
			} else {
				return null;
			}
		}).catch(function(_, err) {
			console.warn(err);
			return null;
		});
	};

	/**
	 * Replace the page content with the MarkBLocked config interface.
	 * @returns {void}
	 * @requires oojs-ui
	 * @requires oojs-ui.styles.icons-moderation
	 * @requires mediawiki.api
	 * @requires mediawiki.user
	 */
	MarkBLocked.prototype.createConfigInterface = function() {

		document.title = 'MarkBLockedConfig - ' + mw.config.get('wgSiteName');

		// Collect DOM elements
		var $heading = $('.mw-first-heading');
		var $body = $('.mw-body-content');
		if (!$heading.length || !$body.length) {
			mw.notify(this.getMessage('config-notloaded'));
			return;
		}
		$heading.text(this.getMessage('config-heading'));

		// Config container
		var $container = $('<div>').prop('id', 'mblc-container');
		$body.empty().append($container);

		// Transparent overlay of the container used to make elements in it unclickable
		var $overlay = $('<div>').prop('id', 'mblc-container-overlay').hide();
		$container.after($overlay);

		// Option container fieldset
		var fieldset = new OO.ui.FieldsetLayout({
			id: 'mblc-optionfield',
			label: this.getMessage('config-label-fieldset')
		});
		$container.append(fieldset.$element);

		// Options
		var localIps = new OO.ui.CheckboxInputWidget({
			selected: this.options.localips
		});
		var globalUsers = new OO.ui.CheckboxInputWidget({
			selected: this.options.globalusers
		});
		var globalIps = new OO.ui.CheckboxInputWidget({
			selected: this.options.globalips
		});
		fieldset.addItems([
			new OO.ui.FieldLayout(localIps, {
				label: this.getMessage('config-label-localips'),
				align: 'inline'
			}),
			new OO.ui.FieldLayout(globalUsers, {
				label: this.getMessage('config-label-globalusers'),
				align: 'inline'
			}),
			new OO.ui.FieldLayout(globalIps, {
				label: this.getMessage('config-label-globalips'),
				align: 'inline'
			})
		]);

		// Save button
		var saveButton = new OO.ui.ButtonWidget({
			id: 'mblc-save',
			label: this.getMessage('config-label-save'),
			icon: 'bookmarkOutline',
			flags: ['primary', 'progressive']
		});
		$container.append(saveButton.$element);
		var _this = this;
		saveButton.$element.off('click').on('click', function() {

			$overlay.show();

			// Change the save button's label
			var $img = $('<img>')
				.prop('src', '//upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif')
				.css({
					verticalAlign: 'middle',
					height: '1em',
					border: '0',
					marginRight: '1em'
				});
			var $label = $('<span>').append($img);
			var textNode = document.createTextNode(_this.getMessage('config-label-saving'));
			$label.append(textNode);
			saveButton.setIcon(null).setLabel($label);

			// Get config
			var /** @type {UserOptions} */ cfg = {
				localips: localIps.isSelected(),
				globalusers: globalUsers.isSelected(),
				globalips: globalIps.isSelected()
			};
			var strCfg = JSON.stringify(cfg);

			// Save config
			api.saveOption(_this.optionKey, strCfg)
				.then(function() {
					mw.user.options.set(_this.optionKey, strCfg);
					return null;
				})
				.catch(/** @param {string} code */ function(code, err) {
					console.warn(err);
					return code;
				})
				.then(/** @param {string?} err */ function(err) {
					if (err) {
						mw.notify(_this.getMessage('config-label-savefailed') + '(' + err + ')', {type: 'error'});
					} else {
						mw.notify(_this.getMessage('config-label-savedone'), {type: 'success'});
					}
					saveButton.setIcon('bookmarkOutline').setLabel(_this.getMessage('config-label-save'));
					$overlay.hide();
				});

		});

	};

	/**
	 * Create a portlet link to the config page.
	 * @returns {void}
	 * @requires mediawiki.util
	 */
	MarkBLocked.prototype.createPortletLink = function() {
		var portlet = mw.util.addPortletLink(
			'p-tb',
			mw.util.getUrl('Special:MarkBLockedConfig'),
			this.getMessage('portlet-text'),
			'ca-mblc'
		);
		if (!portlet) {
			console.error('Failed to create a portlet link for MarkBLocked.');
		}
	};

	/**
	 * Mark up user links.
	 * @param {JQuery<HTMLElement>} $content
	 * @returns {void}
	 * @requires mediawiki.util
	 * @requires mediawiki.api
	 */
	MarkBLocked.prototype.markup = function($content) {

		var collected = this.collectLinks($content);
		var userLinks = collected.userLinks;
		if ($.isEmptyObject(userLinks)) {
			console.log('MarkBLocked', {
				links: 0
			});
			return;
		}
		var users = collected.users;
		var ips = collected.ips;
		var allUsers = users.concat(ips);
		var options = this.options;

		this.markBlockedUsers(userLinks, allUsers).then(function(markedUsers) {

			if (markedUsers === null) { // Aborted
				return;
			} else {
				console.log('MarkBLocked', {
					links: $('.mbl-userlink').length,
					user_registered: users.length,
					user_anonymous: ips.length
				});
			}

			// Create a batch array for additional markups
			var ipsThatMightBeBlocked = ips.filter(function(ip) {
				return markedUsers.indexOf(ip) === -1;
			});
			var /** @type {BatchObject[]} */ batchArray = [];
			if (options.localips && ipsThatMightBeBlocked.length) {
				ipsThatMightBeBlocked.forEach(function(ip) {
					batchArray.push({
						params: {
							action: 'query',
							list: 'blocks',
							bklimit: 1,
							bkip: ip,
							bkprop: 'user|expiry|restrictions',
							formatversion: '2'
						},
						callback: function(res) {
							var /** @type {ApiResponseQueryListBlocks[]=} */ resBlk = res && res.query && res.query.blocks;
							var /** @type {ApiResponseQueryListBlocks=} */ resObj;
							if (resBlk && (resObj = resBlk[0])) {
								var partialBlk = resObj.restrictions && !Array.isArray(resObj.restrictions);
								var clss;
								if (/^in/.test(resObj.expiry)) {
									clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-indef';
								} else {
									clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-temp';
								}
								addClass(userLinks, ip, clss);
							}
						}
					});
				});
			}
			if (options.globalusers && users.length) {
				users.forEach(function(user) {
					batchArray.push({
						params: {
							action: 'query',
							list: 'globalallusers',
							agulimit: 1,
							agufrom: user,
							aguto: user,
							aguprop: 'lockinfo',
							formatversion: '2'
						},
						callback: function(res) {
							/** @typedef {{locked?: string;}} ApiResponseQueryListGlobalallusers */
							var /** @type {ApiResponseQueryListGlobalallusers[]=} */ resLck = res && res.query && res.query.globalallusers;
							var /** @type {ApiResponseQueryListGlobalallusers=} */ resObj;
							if (resLck && (resObj = resLck[0]) && resObj.locked === '') {
								addClass(userLinks, user, 'mbl-globally-locked');
							}
						}
					});
				});
			}
			if (options.globalips && ips.length) {
				ips.forEach(function(ip) {
					batchArray.push({
						params: {
							action: 'query',
							list: 'globalblocks',
							bgip: ip,
							bglimit: 1,
							bgprop: 'address|expiry',
							formatversion: '2'
						},
						callback: function(res) {
							/** @typedef {{expiry: string;}} ApiResponseQueryListGlobalblocks */
							var /** @type {ApiResponseQueryListGlobalblocks[]=} */ resGblk = res && res.query && res.query.globalblocks;
							var /** @type {ApiResponseQueryListGlobalblocks=} */ resObj;
							if (resGblk && (resObj = resGblk[0])) {
								var clss = /^in/.test(resObj.expiry) ? 'mbl-globally-blocked-indef' : 'mbl-globally-blocked-temp';
								addClass(userLinks, ip, clss);
							}
						}
					});
				});
			}

			if (batchArray.length) {
				batchRequest(batchArray);
			}

		});

	};

	/**
	 * Object that stores collected user links, keyed by usernames and valued by an array of anchors.
	 * @typedef {Object.<string, HTMLAnchorElement[]>} UserLinks
	 */
	/**
	 * Collect user links to mark up.
	 * @param {JQuery<HTMLElement>} $content
	 * @returns {{userLinks: UserLinks; users: string[]; ips: string[];}}
	 * @requires mediawiki.util
	 */
	MarkBLocked.prototype.collectLinks = function($content) {

		// Get all anchors in the page content
		var $anchors = $content.find('a');
		var $pNamespaces = $('#p-associated-pages');
		if ($pNamespaces.length && !$content.find($pNamespaces).length) { // Add links in left navigation
			$anchors = $anchors.add($pNamespaces.find('a'));
		}

		// Set up variables
		var _this = this;
		var /** @type {string[]} */ users = [];
		var /** @type {string[]} */ ips = [];
		var ignoredClassesPr = /\b(mw-rollback-|autocomment)/;
		var /** @type {UserLinks} */ userLinks = {};

		// Filter out user links
		$anchors.each(function(_, a) {

			// Ignore some anchors
			var href = a.href;
			var pr = a.parentElement;
			if (
				!href ||
				href[0] === '#' ||
				mw.util.getParamValue('action', href) && !mw.util.getParamValue('redlink', href) ||
				mw.util.getParamValue('diff', href) ||
				mw.util.getParamValue('oldid', href) ||
				a.type === 'button' ||
				a.role === 'button' ||
				pr && ignoredClassesPr.test(pr.className)
			) {
				return;
			}

			// Get the associated pagetitle
			var /** @type {RegExpExecArray?} */ m,
				/** @type {string} */ pagetitle;
			if ((m = _this.regex.article.exec(href))) {
				pagetitle = m[1];
			} else if ((m = _this.regex.script.exec(href))) {
				pagetitle = m[1];
			} else {
				return;
			}
			pagetitle = decodeURIComponent(pagetitle).replace(/ /g, '_');

			// Extract a username from the pagetitle
			m = _this.regex.user.exec(pagetitle);
			if (!m) {
				return;
			}
			var username = m[1].replace(/_/g, ' ').trim();
			var /** @type {string[]} */ arr;
			if (mw.util.isIPAddress(username, true)) {
				// @ts-ignore
				username = mw.util.sanitizeIP(username) || username; // The right operand is never reached
				arr = ips;
			} else if (/[/@#<>[\]|{}:]|^(\d{1,3}\.){3}\d{1,3}$/.test(username)) {
				// Ensure the username doesn't contain characters that can't be used for usernames (do this here or block status query might fail)
				console.log('MarkBLocked: Unprocessable username: ' + username);
				return;
			} else {
				arr = users;
				if (!/^[\u10A0-\u10FF]/.test(username)) { // ucFirst, except for Georgean letters
					username = username.charAt(0).toUpperCase() + username.slice(1);
				}
			}
			if (arr.indexOf(username) === -1) {
				arr.push(username);
			}

			a.classList.add('mbl-userlink');
			if (userLinks[username]) {
				userLinks[username].push(a);
			} else {
				userLinks[username] = [a];
			}

		});

		return {
			userLinks: userLinks,
			users: users,
			ips: ips
		};

	};

	/**
	 * @typedef ApiResponseQueryListBlocks
	 * @type {object}
	 * @property {[]|{}} [restrictions]
	 * @property {string} expiry
	 * @property {string} user
	 */
	/**
	 * Mark up locally blocked registered users and single IPs (this can't detect single IPs included in blocked IP ranges)
	 * @param {UserLinks} userLinks
	 * @param {string[]} usersArr
	 * @returns {JQueryPromise<string[]?>} Usernames whose links are marked up (`null` if aborted).
	 * @requires mediawiki.api
	 */
	MarkBLocked.prototype.markBlockedUsers = function(userLinks, usersArr) {

		if (!usersArr.length) {
			return $.Deferred().resolve([]);
		} else {
			usersArr = usersArr.slice(); // Deep copy
		}

		var /** @type {string[]} */ marked = [];
		var aborted = false;
		/**
		 * @param {string[]} users
		 * @returns {JQueryPromise<void>}
		 */
		var req = function(users) {
			return api.post({ // This MUST be a POST request because the parameters can exceed the word count limit of URI
				action: 'query',
				list: 'blocks',
				bklimit: 'max',
				bkusers: users.join('|'),
				bkprop: 'user|expiry|restrictions',
				formatversion: '2'
			}).then(function(res){
				var /** @type {ApiResponseQueryListBlocks[]=} */ resBlk = res && res.query && res.query.blocks;
				if (resBlk) {
					resBlk.forEach(function(obj) {
						var partialBlk = obj.restrictions && !Array.isArray(obj.restrictions); // Boolean: True if partial block
						var clss;
						if (/^in/.test(obj.expiry)) {
							clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-indef';
						} else {
							clss = partialBlk ? 'mbl-blocked-partial' : 'mbl-blocked-temp';
						}
						var markedUser = addClass(userLinks, obj.user, clss);
						if (markedUser) {
							marked.push(markedUser);
						}
					});
				}
				return void 0;
			}).catch(function(_, err) {
				// @ts-ignore
				if (err.exception === 'abort') {
					aborted = true;
				} else {
					console.error(err);
				}
				return void 0;
			});
		};

		// API calls
		var /** @type {JQueryPromise<void>[]} */ deferreds = [];
		while (usersArr.length) {
			deferreds.push(req(usersArr.splice(0, this.apilimit)));
		}
		return $.when.apply($, deferreds).then(function() {
			return aborted ? null : marked;
		});

	};

	/**
	 * Add a class to all anchors associated with a certain username.
	 * @param {UserLinks} userLinks
	 * @param {string} userName
	 * @param {string} className
	 * @returns {string?} The username if any link is marked up, or else `null`.
	 */
	function addClass(userLinks, userName, className) {
		var links = userLinks[userName]; // Get all links related to the user
		if (links) {
			for (var i = 0; links && i < links.length; i++) {
				links[i].classList.add(className);
			}
			return userName;
		} else {
			console.error('MarkBLocked: There\'s no link for User:' + userName);
			return null;
		}
	}

	/**
	 * @typedef {Object.<string, any>} DynamicObject
	 */
	/**
	 * @typedef BatchObject
	 * @type {object}
	 * @property {DynamicObject} params
	 * @property {(res?: DynamicObject) => void} callback
	 */
	/**
	 * Send batched API requests.
	 *
	 * MarkBLocked has to send quite a few API requests when additional markup functionalities are enabled,
	 * and this can lead to an `net::ERR_INSUFFICIENT_RESOURCES` error if too many requests are sent all
	 * at once. This (private) function sends API requests by creating batches of 1000, where each batch is
	 * processed sequentially after the older batch is resolved.
	 * @param {BatchObject[]} batchArray
	 * @returns {JQueryPromise<void>}
	 * @requires mediawiki.api
	 */
	function batchRequest(batchArray) {

		// Unflatten the array of objects to an array of arrays of objects
		var unflattened = batchArray.reduce(/** @param {BatchObject[][]} acc */ function(acc, obj) {
			var len = acc.length - 1;
			if (Array.isArray(acc[len]) && acc[len].length < 1000) {
				acc[len].push(obj);
			} else {
				acc[len + 1] = [obj];
			}
			return acc;
		}, [[]]);

		var aborted = false;
		/**
		 * Send an API request.
		 * @param {BatchObject} batchObj
		 * @returns {JQueryPromise<void>}
		 */
		var req = function(batchObj) {
			return api.get(batchObj.params)
				.then(batchObj.callback)
				.catch(function(_, err) {
					// @ts-ignore
					if (err.exception === 'abort') {
						aborted = true;
					} else {
						console.error(err);
					}
					return void 0;
				});
		};
		/**
		 * Send batched API requests.
		 * @param {number} index
		 * @returns {JQueryPromise<void>}
		 */
		var batch = function(index) {
			var batchElementArray = unflattened[index];
			var /** @type {JQueryPromise<void>[]} */ deferreds = [];
			batchElementArray.forEach(function(batchObj) {
				deferreds.push(req(batchObj));
			});
			return $.when.apply($, deferreds).then(function() {
				console.log('MarkBLocked batch count: ' + deferreds.length);
				index++;
				if (!aborted && unflattened[index]) {
					return batch(index);
				} else {
					return void 0;
				}
			});
		};

		return batch(0);

	}

	return MarkBLocked;

})();
//</nowiki>