「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 = []; |
|||
} |
|||
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'); |
|||
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); |
|||
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>