Jump to content

User:DatGuy/mark-blocked.js

From Meta, a Wikimedia project coordination wiki

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-DG
 * @author Dragoniez
 * @author DatGuy
 * @link https://www.meta.wikimedia.org/wiki/User:DatGuy/mark-blocked.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 [[mediawiki:User:Dragoniez/Gadget-MarkBLocked.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>
/* jshint esversion: 8 */

(function(mw, $) { // Wrapper function

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

    var api;
    var metaApi;
    /** @readonly */
    var MarkBLocked = mw.libs.MarkBLocked = {
        // ********************************************** LOCALIZATION SETTINGS **********************************************

        /**
         * Portletlink configurations
         * @static
         * @readonly
         */
        portletlink: {
            position: 'p-tb',
            text: 'MarkBLocked Preferences',
            id: 't-mblp',
            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',
            tooltip: 'Tooltip to use. $prev_text will default to the username if undefined',
            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: {
            tooltip: '$prev_text; $action_type ($duration) by $blocker: $reason ($time ago)',
            localips: false,
            globalusers: false,
            globalips: false
        },

        /**
         * @static
         * @readonly
         */
        saveOptionName: 'userjs-mbl-preferences',

        /**
         * @requires mediawiki.user
         * @requires mediawiki.util
         * @requires mediawiki.api
         */
        init: function() {

            api = new mw.Api();
            metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');

            // 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.markupUserLinks, 100);
                    // Check if CSS still works
                });
            }

        },

        /**
         * @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 = 'mblp-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];
                supplementInput(appendTo, id, labelText, appendBr);
                return checkbox;
            };

            /**
             * @param {HTMLElement} appendTo
             * @param {string} id
             * @param {string} labelText
             * @param {boolean} [appendBr]
             * @returns {HTMLInputElement} text input
             */
            var createTextInput = function(appendTo, id, labelText, appendBr) {
                var textInput = document.createElement('input');
                appendTo.appendChild(textInput);
                textInput.type = 'text';
                textInput.id = id;
                var belowHyphen = id.replace(/^[^-]+-/, '');
                textInput.value = MarkBLocked.prefs[belowHyphen];
                supplementInput(appendBr, id, labelText, appendBr);
                return textInput;
            };

            var supplementInput = function(appendTo, id, labelText, appendBr) {
                var label = document.createElement('label');
                appendTo.appendChild(label);
                label.htmlFor = id;
                label.appendChild(document.createTextNode(labelText));
                if (appendBr) appendTo.appendChild(document.createElement('br'));
            };

            var bodyDiv = document.createElement('div');
            container.appendChild(bodyDiv);
            bodyDiv.id = 'mblp-body';
            var tooltip = createTextInput(bodyDiv, 'mblp-tooltip', MarkBLocked.configpage.tooltip, true);
            var localips = createCheckbox(bodyDiv, 'mblp-localips', MarkBLocked.configpage.check.localips, true);
            var globalusers = createCheckbox(bodyDiv, 'mblp-globalusers', MarkBLocked.configpage.check.globalusers, true);
            var globalips = createCheckbox(bodyDiv, 'mblp-globalips', MarkBLocked.configpage.check.globalips, true);

            var saveBtn = document.createElement('input');
            bodyDiv.appendChild(saveBtn);
            saveBtn.id = 'mblp-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, 'mblp-status');
            var lastsaved = createHiddenP(bodyDiv, 'mblp-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) {
                [tooltip, 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, {
                    tooltip: tooltip.value,
                    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;
        },


        /**
         * @typedef UserLinks
         * @type {Object}
         * @property {Array<HTMLAnchorElement>} processed - Links containing the user that are already processed
         * @property {Array<HTMLAnchorElement>} all - Links containing the user
         */

        /**
         * @typedef UserDetails
         * @type {Object}
         * @property {UserLinks} links - Links containing the user
         * @property {UserStatus} status - (B)lock statuses for user
         */

        // Memory usage remains a question, but I think it should be fine
        /** {'username': {'links': {'processed': [], 'all': [<link1>, ...], 'status': {'rangeblock': {'api': api, 'xhr': xhr}, ...}, ...}, */
        /** @type {Map<string, UserDetails>} */
        usersMap: new Map(),


        /** Function to find all user links that match the RegExps and save them into 'usersMap', and then send API requests to check (b)lock status */
        markupUserLinks: async 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 {Set<string>} */
            const regUsers = new Set();
            /** @type {Set<string>} */
            const ips = new Set();
            const ignoredClasses = /\bmw-changeslist-/;
            const ignoredClassesPr = /\bmw-(history|rollback)-|\bautocomment/;

            anchors.forEach((a) => { // Loop through all links in the page content
                // Ignore some anchors
                if (a.type === 'button' || a.role === 'button') return;

                var firstParent, secondParent;
                if (ignoredClasses.test(a.className) ||
                    (firstParent = a.parentElement) && ignoredClassesPr.test(firstParent.className) ||
                    // cur/prev revision links
                    firstParent && (secondParent = firstParent.parentElement) && secondParent.classList.contains('mw-history-histlinks') && secondParent.classList.contains('mw-changeslist-links')
                ) {
                    return;
                }

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

                var m, pageTitle;
                if ((m = regex.article.exec(hrefUrl))) {
                    pageTitle = m[1];
                } else if ((m = regex.script.exec(hrefUrl))) {
                    pageTitle = m[1];
                } else {
                    return;
                }
                pageTitle = decodeURIComponent(pageTitle).replace(/ /g, '_'); // Was previously (/_/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)) {
                    if (mw.util.isIPv6Address(username, true)) username = username.toUpperCase(); // Alphabets in IPv6s are case-insensitive
                    ips.add(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]]
                        regUsers.add(username);
                    }
                }

                // Add a class to this anchor and save the anchor into the map
                a.classList.add('mbl-userlink');
                if (!MarkBLocked.usersMap.has(username)) MarkBLocked.usersMap.set(username, { links: { processed: [], all: [] }, status: {} });
                if (!MarkBLocked.usersMap.get(username).links.all.includes(a)) {
                    MarkBLocked.usersMap.get(username).links.all.push(a);
                }
            });
            if (MarkBLocked.usersMap.size === 0 || (regUsers.size === 0 && ips.size === 0)) return;

            const lockedUsersPromise = async (regUsers) => { if (MarkBLocked.prefs.globalusers) return MarkBLocked.markLockedUsers(regUsers); };
            const blockedIPsPromise = async (ips) => { if (MarkBLocked.prefs.localips) return MarkBLocked.markBlockedIPs(ips); };
            const gBlockedIPsPromise = async (ips) => { if (MarkBLocked.prefs.globalips) return MarkBLocked.markGloballyBlockedIps(ips); };
            // Check (b)lock status and mark up the links if needed
            await Promise.all([
                MarkBLocked.markBlockedUsers(regUsers),
                lockedUsersPromise(regUsers),
                blockedIPsPromise(ips),
                gBlockedIPsPromise(ips)
            ]).then(() => {
                MarkBLocked.usersMap.forEach((userDetails, userName) => {
                    MarkBLocked.usersMap.get(userName).links = {
                        processed: [...userDetails.links.all],
                        all: []
                    };
                });
            });
        },

        /**
         * Get the local block status of registered users and single IPs (this can't detect single IPs included in blocked IP ranges)
         * @param {Set<string>} referenceUsersSet
         * @return {Array<Promise>} apiPromises
         */
        markBlockedUsers: async function(referenceUsersSet) {
            const parseQueryResponse = (res, xhr) => {
                let resBlock;
                if (!res || !res.query || !(resBlock = res.query.blocks) || resBlock.length === 0) return;

                const serverTime = new Date(xhr.getResponseHeader('Date'));
                resBlock.forEach(function(block) {
                    MarkBLocked.markBlock(undefined, serverTime, block, false, false);
                });
            };
            // Create shallow copy
            const usersArray = Array.from(referenceUsersSet);
            const bkLimit = MarkBLocked.hasApiHighlimits ? 500 : 50; // Better performance for users with 'apiHighLimits'

            /** @type {Array<Promise>} **/
            const apiPromises = [];
            // Send API calls
            while (usersArray.length > 0) {
                apiPromises.push(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: usersArray.splice(0, bkLimit).join('|'),
                    bkprop: 'user|by|timestamp|expiry|reason|restrictions',
                    formatversion: '2'
                }).done(parseQueryResponse)
                .fail(function(code, err) {
                    mw.log.error(err.error.info);
                }));
            }
            return Promise.all(apiPromises);
        },

        /**
         * Function to check whether single IPs are locally blocked AND whether they are included in any locally-blocked IP range
         * @param {Set<string>} ipsSet
         * @return {Array<Promise>} apiPromises
         */
        markBlockedIPs: async function(ipsSet) {
            const parseQueryResponse = (currentIP, res, xhr) => {
                let resBlock;
                // noinspection JSUnresolvedVariable
                if (!res || !res.query || !(resBlock = res.query.blocks) || resBlock.length === 0) return;

                const serverTime = new Date(xhr.getResponseHeader('Date'));
                resBlock.forEach(function(block) {
                    MarkBLocked.markBlock(currentIP, serverTime, block, block.rangestart !== block.rangeend, false);
                });
            };

            const ipsArray = [...ipsSet];
            const fetchedIPs = ipsArray.filter((currentIP) => 'rangeblock' in MarkBLocked.usersMap.get(currentIP).status);
            fetchedIPs.forEach((currentIP) => {
                const ipRangeBlockInfo = MarkBLocked.usersMap.get(currentIP).status.rangeblock;
                parseQueryResponse(currentIP, ipRangeBlockInfo.api, ipRangeBlockInfo.xhr);
            });

            return Promise.all(ipsArray.filter((currentIP) => !fetchedIPs.includes(currentIP)).map((currentIP) => {
                return api.post({
                    action: 'query',
                    list: 'blocks',
                    bklimit: '10',
                    bkip: currentIP,
                    bkprop: 'user|by|timestamp|expiry|range|reason|restrictions',
                    formatversion: '2'
                }).done(function(res, xhr) {
                    parseQueryResponse(currentIP, res, xhr);
                    MarkBLocked.usersMap.get(currentIP).status.rangeblock = { api: res, xhr };
                }).fail(function(code, err) {
                    mw.log.error(err.error.info);
                });
            }));
        },

        /**
         * Get the global lock status of registered users
         * @param {Set<string>} regUsersSet
         * @return {Array<Promise>} apiPromises
         */
        markLockedUsers: async function(regUsersSet) {
            const parseQueryResponse = (currentUser, res) => {
                // If the length is 0, then we couldn't find the log event
                if (res.query.logevents.length > 0) {
                    const logEvent = res.query.logevents[0];
                    if (logEvent.params.added && logEvent.params.added.includes('locked')) {
                        const prettyTimestamp = MarkBLocked.formatTime(new Date(), new Date(logEvent.timestamp));

                        let tooltip = MarkBLocked.BuildTooltip('locked', '', currentUser, logEvent.user, logEvent.comment, prettyTimestamp);

                        const userLinks = MarkBLocked.usersMap.get(currentUser).links;
                        const links = userLinks.all.filter((link) => !userLinks.processed.includes(link));
                        links.forEach((link) => {
                            link.classList.add('mbl-globally-locked');
                            tooltip = tooltip.replace('$prev_text', link.getAttribute('title') || currentUser);
                            link.setAttribute('title', tooltip);
                        });
                    }
                }
            };

            const usersArray = [...regUsersSet];
            const fetchedUsers = usersArray.filter((currentUser) => 'lock' in MarkBLocked.usersMap.get(currentUser).status);
            fetchedUsers.forEach((currentUser) => {
                const userLockInfo = MarkBLocked.usersMap.get(currentUser).status.lock;
                parseQueryResponse(currentUser, userLockInfo.api);
            });

            return Promise.all(usersArray.filter((currentUser) => !fetchedUsers.includes(currentUser)).map((regUser) => {
                return metaApi.get({
                    action: 'query',
                    list: 'logevents',
                    leprop: 'user|timestamp|comment|details',
                    leaction: 'globalauth/setstatus',
                    letitle: `User:${regUser}@global`,
                    formatversion: '2'
                }).done((res) => {
                    parseQueryResponse(regUser, res);
                    MarkBLocked.usersMap.get(regUser).status.lock = { api: res };
                }).fail((code, err) => {
                    mw.log.error(err.error.info);
                });
            }));
        },

        /**
         * Get the global block status of IPs; can check whether single IPs are included in globally-blocked IP ranges
         * @param {Set<string>} ipsSet
         * @return {Array<Promise>} apiPromises
         */
        markGloballyBlockedIps: async function(ipsSet) {
            const parseQueryResponse = (currentIP, res, xhr) => {
                // noinspection DuplicatedCode
                let resBlock;
                if (!res || !res.query || !(resBlock = res.query.globalblocks) || resBlock.length === 0) return;

                const serverTime = new Date(xhr.getResponseHeader('Date'));
                resBlock.forEach(function(block) {
                    MarkBLocked.markBlock(currentIP, serverTime, block, block.rangestart !== block.rangeend, true);
                });
            };

            const ipsArray = [...ipsSet];
            const fetchedIPs = ipsArray.filter((currentIP) => 'globalblock' in MarkBLocked.usersMap.get(currentIP).status);
            fetchedIPs.forEach((currentIP) => {
                const ipGlobalBlockInfo = MarkBLocked.usersMap.get(currentIP).status.globalblock;
                parseQueryResponse(currentIP, ipGlobalBlockInfo.api, ipGlobalBlockInfo.xhr);
            });

            return Promise.all(ipsArray.filter((currentIP) => !fetchedIPs.includes(currentIP)).map(function(currentIP) {
                return api.post({
                    action: 'query',
                    list: 'globalblocks',
                    bglimit: '10',
                    bgip: currentIP,
                    bgprop: 'address|by|timestamp|expiry|range|reason',
                    formatversion: '2'
                }).done(function(res, xhr) {
                    parseQueryResponse(currentIP, res, xhr);
                    MarkBLocked.usersMap.get(currentIP).status.globalblock = { api: res, xhr };
                }).fail(function(code, err) {
                    mw.log.error(err.error.info);
                });
            }));
        },



        /** Marks the link with the block from all the information provided
         * @param {string} userLinkName Name of user to mark for
         * @param {Date} serverTime Time the server gave us
         * @param {object} block
         * @param {boolean} rangeblock Whether it's a rangeblock
         * @param {boolean} globalBlock Whether it's a global block
         */
        markBlock: function(userLinkName, serverTime, block, rangeblock, globalBlock) {
            let linkClass;
            let blockType = rangeblock ? 'rangeblocked' : 'blocked';
            // noinspection JSUnresolvedVariable
            const partialBlock = block.restrictions && !Array.isArray(block.restrictions); // Boolean: True if partial block
            if (partialBlock) {
                blockType = 'partially ' + blockType;
                linkClass = 'mbl-blocked-partial';
            }
            if (globalBlock) blockType = 'globally ' + blockType;

            let blockDuration = block.expiry;
            const blockTimestamp = new Date(block.timestamp);

            if (block.expiry.includes('in')) { // Has the value 'infinity' if blocked indefinitely
                if (!partialBlock) linkClass = globalBlock ? 'mbl-globally-blocked-indef' : 'mbl-blocked-indef';
            } else {
                if (!partialBlock) linkClass = globalBlock ? 'mbl-globally-blocked-temp' : 'mbl-blocked-temp';
                blockDuration = MarkBLocked.formatTime(new Date(block.expiry), blockTimestamp);
            }

            // block.address for globally blocked IPs
            const blockTarget = block.user || block.address;
            // noinspection JSUnresolvedVariable
            let tooltip = MarkBLocked.BuildTooltip(blockType, blockDuration, blockTarget, block.by, block.reason, MarkBLocked.formatTime(serverTime, blockTimestamp));

            const userLinks = MarkBLocked.usersMap.get(userLinkName || blockTarget).links; // Get all links related to the user
            const links = userLinks.all.filter((link) => !userLinks.processed.includes(link));
            links.forEach((link) => {
                link.classList.add(linkClass);
                tooltip = tooltip.replace('$prev_text', link.getAttribute('title') || blockTarget);
                link.setAttribute('title', tooltip);
            });
        },

        /** Builds tooltip from provided values
         * @param {string} actionType Type of action, i.e. 'globally blocked', 'rangeblocked', etc.
         * @param {string} actionDuration Prettified duration of (b)lock, e.g. 3d
         * @param {string} actionTarget (B)lock target
         * @param {string} actionPerformer (B)lock performer
         * @param {string} actionReason (B)lock reason
         * @param {string} actionTimeSince Time since (b)lock occured
         * @return {string} A formatted tooltip to display
         */
        BuildTooltip: function(actionType, actionDuration, actionTarget, actionPerformer, actionReason, actionTimeSince) {
            let tooltip = MarkBLocked.prefs.tooltip;
            if (actionDuration === '') {
                tooltip = tooltip.replace(' ($duration)', ''); // Bad.
            }
            return tooltip
                .replace('$action_type', actionType)
                .replace('$target', actionTarget)
                .replace('$duration', actionDuration)
                .replace('$blocker', actionPerformer)
                .replace('$reason', actionReason)
                .replace('$time', actionTimeSince);
        },

        /**
         * Formats time from existingTime to targetTime, e.g. 259200 seconds -> 3d
         * @param {Date} startTime Time to start the comparison at
         * @param {Date} targetTime Time to end the comparison at
         * @returns {string} Prettified time string
         */
        formatTime: function(startTime, targetTime) {
            const padNumber = (number) => number <= 9 ? '0' + number : number;

            const timeDifference = startTime - targetTime;

            let minutes = Math.floor(timeDifference / 60000);
            if (!minutes) {
                return Math.floor(timeDifference / 1000) + 's';
            }

            let hours = Math.floor(minutes / 60);
            minutes %= 60;

            const days = Math.floor(hours / 24);
            hours %= 24;
            if (days) {
                return `${days}${(days < 10 ? '.' + padNumber(hours) : '')}d`;
            }
            return `${hours}:${padNumber(minutes)}`;
        }
    };

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

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

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