MediaWiki:Gadget-MarkBLocked.js
/**
* Gadget-MarkBLocked (GMBL)
* @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).
*/
//
(function(mw, $) { // Wrapper function
// *******************************************************************************************************************
var api;
/** @readonly */
var MarkBLocked = mw.libs.MarkBLocked = {
// ********************************************** LOCALIZATION SETTINGS **********************************************
/**
* Portletlink configurations
* @static
* @readonly
*/
portletlink: {
position: 'p-tb',
text: 'MarkBLockedの設定',
id: 't-gmblp',
tooltip: 'MarkBLockedの設定を変更する',
accesskey: null,
nextnode: null
},
/**
* Register all local page names for Special:Contributions and Special:CentralAuth (without the namespace prefix).
* 'contribs', 'contributions', 'ca', and 'centralauth' are registered by default: No need to register them. Note that the
* items are case-insensitive, compatible both with " " and "_" for spaces, and should NEVER be URI-encoded. If nothing
* needs to be registered, leave the array empty.
* @static
* @readonly
*/
contribs_CA: ['投稿記録', 'アカウント統一管理'], // Example setting for jawiki
/**
* Texts to show on Special:MarkBLockedPreferences
* @static
* @readonly
*/
configpage: {
heading: 'MarkBLockedの設定',
check: {
localips: 'ブロックされたIPレンジに含まれる単一IPをマークアップする',
globalusers: 'グローバルロックを受けた利用者をマークアップする',
globalips: 'グローバルブロックを受けたIPをマークアップする'
},
save: {
button: '保存',
doing: '保存しています',
done: '保存しました',
failed: '保存に失敗しました',
lastsave: '最終保存:' // This is FOLLOWED by a space and a timestamp
}
},
/**
* Names of the local user groups that have the 'apihighlimits' user right
* @static
* @readonly
*/
apihighlimits: ['bot', 'sysop'],
// *******************************************************************************************************************
/**
* The keys are namespace numbers. The values are arrays of corresponding aliases.
* ```
* console.log(nsAliases[3]); // ['user_talk'] - Always in lowercase and spaces are represented by underscores.
* ```
* @type {Object.<number, Array>}
* @static
* @readonly
*/
nsAliases: (function() {
/** @type {Object.} */
var nsObj = mw.config.get('wgNamespaceIds'); // {"special": -1, "user": 2, ...}
/** @type {Object.<number, Array>} */
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);
})(),
/**
* 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}
*/
getAliases: function(nsNumberArray, stringifyWith) {
/** @type {Array} */
var aliasesArr = [];
nsNumberArray.forEach(function(nsNumber) {
aliasesArr = aliasesArr.concat(MarkBLocked.nsAliases[nsNumber]);
});
return typeof stringifyWith === 'string' ? aliasesArr.join(stringifyWith) : aliasesArr;
},
hasApiHighlimits: false,
prefs: {
localips: false,
globalusers: false,
globalips: false
},
/**
* @static
* @readonly
*/
saveOptionName: 'userjs-gmbl-preferences',
/**
* @requires mediawiki.user
* @requires mediawiki.util
* @requires mediawiki.api
*/
init: function() {
api = new mw.Api();
// Initialize MarkBLocked.hasApiHighlimits
var userGroups = MarkBLocked.apihighlimits.concat([
'apihighlimits-requestor',
'founder',
'global-bot',
'global-sysop',
'staff',
'steward',
'sysadmin',
'wmf-researcher'
]);
MarkBLocked.hasApiHighlimits = mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups')).some(function(group) {
return userGroups.indexOf(group) !== -1;
});
// 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
if (mw.config.get('wgAction') !== 'edit' || // Not on an edit page, or
document.querySelector('.mw-logevent-loglines') // There's a notification box for delete, block, etc.
) {
var hookTimeout;
mw.hook('wikipage.content').add(function() {
clearTimeout(hookTimeout); // Prevent hook from being triggered multiple times
hookTimeout = setTimeout(MarkBLocked.collectUserLinks, 100);
});
}
},
/**
* @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;">'
},
createPreferencesPage: function() {
document.title = 'MarkBLockedPreferences - Wikipedia';
var container = document.createElement('div');
container.id = 'gmblp-container';
/**
* @param {HTMLElement} appendTo
* @param {string} id
* @param {string} labelText
* @param {boolean} [appendBr]
* @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;
};
var bodyDiv = document.createElement('div');
container.appendChild(bodyDiv);
bodyDiv.id = 'gmblp-body';
var localips = createCheckbox(bodyDiv, 'gmblp-localips', MarkBLocked.configpage.check.localips, true);
var globalusers = createCheckbox(bodyDiv, 'gmblp-globalusers', MarkBLocked.configpage.check.globalusers, true);
var globalips = createCheckbox(bodyDiv, 'gmblp-globalips', MarkBLocked.configpage.check.globalips, true);
var saveBtn = document.createElement('input');
bodyDiv.appendChild(saveBtn);
saveBtn.id = 'gmblp-save';
saveBtn.type = 'button';
saveBtn.style.marginTop = '1em';
saveBtn.value = MarkBLocked.configpage.save.button;
/**
* @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;
};
var status = createHiddenP(bodyDiv, 'gmblp-status');
var lastsaved = createHiddenP(bodyDiv, 'gmblp-lastsaved');
// Replace body content. Easier to just replace mw.util.$content[0].innerHTML, but this would remove #p-cactions etc.
var bodyContent = document.querySelector('.mw-body-content') || mw.util.$content[0];
bodyContent.replaceChildren(container);
var firstHeading = document.querySelector('.mw-first-heading');
if (firstHeading) { // The innerHTML of .mw-body-content was replaced
firstHeading.textContent = MarkBLocked.configpage.heading;
} 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);
}
/** @param {boolean} disable */
var toggleDisabled = function(disable) {
[localips, globalusers, globalips, saveBtn].forEach(function(el) {
el.disabled = disable;
});
};
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, {
localips: localips.checked,
globalusers: globalusers.checked,
globalips: globalips.checked
});
var newPrefsStr = JSON.stringify(MarkBLocked.prefs);
// API call to save the preferences
api.saveOption(MarkBLocked.saveOptionName, newPrefsStr)
.then(function() { // Success
status.innerHTML = MarkBLocked.configpage.save.done + ' ' + MarkBLocked.images.check;
lastsaved.style.display = 'block';
lastsaved.textContent = MarkBLocked.configpage.save.lastsave + ' ' + new Date().toJSON().split('.')[0];
mw.user.options.set(MarkBLocked.saveOptionName, newPrefsStr);
}).catch(function(code, err) { // Failure
mw.log.error(err);
status.innerHTML = MarkBLocked.configpage.save.failed + ' '  + MarkBLocked.images.cross;
}).then(function() {
toggleDisabled(false);
msgTimeout = setTimeout(function() { // Hide the progress message after 3.5 seconds
status.style.display = 'none';
status.innerHTML = ;
}, 3500);
});
});
},
/**
* @type article: RegExp, script: RegExp, user: RegExp
* @private
*/
// @ts-ignore
_regex: {},
/**
* @returns article: RegExp, script: RegExp, user: RegExp
*/
getRegex: function() {
if ($.isEmptyObject(MarkBLocked._regex)) {
var user = '(?:' + MarkBLocked.getAliases([2, 3], '|') + '):';
var contribs_CA = MarkBLocked.contribs_CA.length === 0 ?  : '|' + MarkBLocked.contribs_CA.join('|');
contribs_CA = '(?:' + MarkBLocked.getAliases([-1], '|') + '):(?:contrib(?:ution)?s|ca|centralauth' + contribs_CA + ')/';
MarkBLocked._regex = {
article: new RegExp(mw.config.get('wgArticlePath').replace('$1', '([^#?]+)')), // '/wiki/PAGENAME'
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;
},
/**
* @type {Object.>} {'username': [\, \, ...], 'username2': [\, \, ...], ...}
*/
userLinks: {},
collectUserLinks: function() {
/** @type {Array<HTMLAnchorElement>} */
var anchors = Array.prototype.slice.call(mw.util.$content[0].getElementsByTagName('a'));
// Additional anchors outside the content body
var contribsToolLinks = document.querySelector('.mw-contributions-user-tools');
var pNamespaces = document.getElementById('p-namespaces');
[contribsToolLinks, pNamespaces].forEach(function(wrapper) {
if (!wrapper) return;
anchors = anchors.concat(Array.prototype.slice.call(wrapper.getElementsByTagName('a')));
});
if (!anchors.length) return;
var regex = MarkBLocked.getRegex();
/** @type {Array} */
var users = [];
/** @type {Array} */
var ips = [];
var ignoredClasses = /\bmw-changeslist-/;
var ignoredClassesPr = /\bmw-(history|rollback)-|\bautocomment/;
anchors.forEach(function(a) {
if (a.type === 'button') return;
if (a.role === 'button') return;
// Ignore some anchors
var pr, pr2;