User:Dragoniez/Gadget-MarkBLocked.js
Appearance
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
* 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]]).
*/
//<nowiki>
(function(mw, $) { // Wrapper function
// *******************************************************************************************************************
var api;
/** @readonly */
var MarkBLocked = mw.libs.MarkBLocked = {
// ********************************************** LOCALIZATION SETTINGS **********************************************
/**
* Portletlink configurations
* @static
* @readonly
*/
portletlink: {
position: 'p-tb',
text: 'MarkBLocked Preferences',
id: 't-gmblp',
tooltip: 'Configure 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 Preferences',
check: {
localips: 'Check whether single IPs are included in locally-blocked IP ranges',
globalusers: 'Check whether registered users are globally locked',
globalips: 'Check whether IPs are globally blocked'
},
save: {
button: 'Save',
doing: 'Saving preferences',
done: 'Saved preferences',
failed: 'Failed to save preferences',
lastsave: 'Last saved at' // 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<string>>}
* @static
* @readonly
*/
nsAliases: (function() {
/** @type {Object.<string, number>} */
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);
})(),
/**
* 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;
},
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.<string, Array<HTMLAnchorElement>>} {'username': [\<link1>, \<link2>, ...], 'username2': [\<link3>, \<link4>, ...], ...}
*/
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<string>} */
var users = [];
/** @type {Array<string>} */
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;
if (ignoredClasses.test(a.className) ||
(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;
if ((m = regex.article.exec(href))) {
pagetitle = m[1];
} else if ((m = regex.script.exec(href))) {
pagetitle = m[1];
} else {
return;
}
pagetitle = decodeURIComponent(pagetitle).replace(/ /g, '_');
// Extract a username from the page title
if (!(m = regex.user.exec(pagetitle))) return;
var username = m[1].replace(/_/g, ' ');
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);
}
}
// Add a class to this anchor and save the anchor into an array
a.classList.add('gmbl-userlink');
if (!MarkBLocked.userLinks[username]) {
MarkBLocked.userLinks[username] = [a];
} else {
MarkBLocked.userLinks[username].push(a);
}
});
if ($.isEmptyObject(MarkBLocked.userLinks)) return;
// 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 {string} userName
* @param {string} className
*/
addClass: function(userName, className) {
var links = MarkBLocked.userLinks[userName]; // Get all links related to the user
for (var i = 0; links && i < links.length; i++) {
links[i].classList.add(className);
}
},
/**
* Mark up locally blocked registered users and single IPs (this can't detect single IPs included in blocked IP ranges)
* @param {Array<string>} usersArr
*/
markBlockedUsers: function(usersArr) {
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'
/**
* @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;
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>