User:Dragoniez/Gadget-MarkBLocked.js

From mediawiki.org

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)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: 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>