User:Dragoniez/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

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

/** @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.} * @static * @readonly */   nsAliases: (function {        /** @type {Object.} */        var nsObj = mw.config.get('wgNamespaceIds'); // {"special": -1, "user": 2, ...}        /** @type {Object.} */        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 } 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 {

// 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? var prefConfigRegex = new RegExp('^(' + MarkBLocked.getAliases([-1], '|') + '):(markblockedpreferences|mbl?p)$', 'i'); if (prefConfigRegex.test(mw.config.get('wgPageName'))) return MarkBLocked.createPreferencesPage;

// If not, create a portletlink to the preferences page mw.util.addPortletLink(           MarkBLocked.portletlink.position,            '/wiki/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.        ) { mw.hook('wikipage.content').add(function($content) {               MarkBLocked.collectUserLinks($content);            }); }

},

/**    * @static * @readonly */   images: { loading: '', check: '', cross: '' },

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 api = new mw.Api; // The save button can be hit multiple times. There's no need to initialize a mw.Api instance each time. 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 * @private */   // @ts-ignore _regex: {},

/**    * @returns */   getRegex: function { if ($.isEmptyObject(MarkBLocked._regex)) { var user = '(?:' + MarkBLocked.getAliases([2, 3], '|') + '):'; var contribs_CA = MarkBLocked.contribs_CA.length === 0 ? '' : '|' + MarkBLocked.contribs_CA.join('|'); var contribs = '(?:' +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 + ')([^/#]+|[a-f\\d:\\.]+/\\d\\d)$', 'i'), contribs: new RegExp('\\?title=' + contribs.replace(/\/$/, '(?!/)'), 'i'), // Not used. Might be used in the future target: /&target=[^&]+/ // Not used. Might be used in the future };       }        return MarkBLocked._regex; },

/**    * @type {Object.>} {'username': [\, \ , ...], 'username2': [\ , \ , ...], ...} */   userLinks: {},

/**    * @param {HTMLAllCollection} $content */   collectUserLinks: function($content) {

var anchors = $content[0].getElementsByTagName('a'); if (!anchors) return;

var regex = MarkBLocked.getRegex;

/** @type {Array } */ var users = []; /** @type {Array } */ var ips = []; for (var i = 0; i < anchors.length; i++) {

var a = anchors[i]; var href = a.href; if (!href) continue;

var m, pagetitle; var isRedlink = a.classList.contains('new'); // For index.php links, only look at redlinks to exclude meta links like undo, rollback, etc.           if ((m = regex.article.exec(href))) { pagetitle = m[1]; } else if (isRedlink && (m = regex.script.exec(href))) { pagetitle = m[1]; } else { continue; }           pagetitle = decodeURIComponent(pagetitle).replace(/ /g, '_');

// Extract a username from the page title if (!(m = regex.user.exec(pagetitle))) continue; var username = m[1].replace(/_/g, ' '); if (mw.util.isIPAddress(username, true)) { if (mw.util.isIPv6Address(username, true)) username = username.toUpperCase; // IPv6 addresses are case-insensitive if (ips.indexOf(username) === -1) ips.push(username); } else { if (/[/@#<>[\]|{}:]/.test(username)) { // Ensure the username doesn't contain characters that can't be used for usernames continue; } 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 } 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'

var api = new mw.Api; 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 === 0) return;

resBlk.forEach(function(obj) {                   var partialBlk = obj.restrictions && !Array.isArray(obj.restrictions); // Boolean: True if partial block                    var clss;                    if (obj.expiry === 'infinity') {                        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.error.info); });       };

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

},

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

var api = new mw.Api; 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 === 0) return;

resBlk = resBlk[0]; var partialBlk = resBlk.restrictions && !Array.isArray(resBlk.restrictions); var clss; if (resBlk.expiry === 'infinity') { 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.error.info); });       };

// API calls ipsArr.forEach(function(ip) {           query(ip);        });

},

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

var api = new mw.Api; 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 === 0) return;

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

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

// API calls regUsersArr.forEach(function(regUser) {           query(regUser);        });

},

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

var api = new mw.Api; 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 === 0) return;

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

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

// API calls ipsArr.forEach(function(ip) {           query(ip);        });

}

};

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

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

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