User:WhitePhosphorus/js/active sysops.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)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/*
 * active_sysops.js (beta test)
 * @author [[User:WhitePhosphorus]], based on [[User:Hoo man]]'s script under the same name
 * For more info: https://meta.wikimedia.org/wiki/User:WhitePhosphorus/active_sysops
 * 
 * Show the number of active local admins and mark global sysop wikis.
 * Still under active development and everything is subject to change.
 */

$.when( mw.loader.using( ['mediawiki.api', 'oojs-ui-core', 'oojs-ui-widgets'] ), $.ready ).then( function () {
    var api = new mw.Api(),
        metaApi = mw.config.get('wgDBname') === 'metawiki' ? api : new mw.ForeignApi('https://meta.wikimedia.org/w/api.php'),
        wikidataApi = mw.config.get('wgDBname') === 'wikidatawiki' ? api : new mw.ForeignApi('https://www.wikidata.org/w/api.php'),
        isGSWiki = null,
        sysopCount = null,
        sysopTotal = null,
        sysopList = null,
        lastAction = 7*86400,
        GSUnknownBGColor = 'rgba(255, 204, 0, 0.8)',
        GSBGColor = 'rgba(0, 170, 102, 0.8)',
        NonGSBGColor = 'rgba(255, 204, 187, 0.8)',
        popupButton = null,
        sysopLimit = 10;

    function displayCORSWarning() {
        // Display the warning only once for the same site
        if (!mw.user.options.get('userjs-p4-cors-warning-displayed')) {
            // Notify user that we cannot access foreign api
            mw.notify($('<p>Some features of active_sysops.js are not available on this wiki. (<a target="_blank" href="https://meta.wikimedia.org/wiki/User:WhitePhosphorus/active_sysops#CORS_Error">Why?</a>)</p>'), {
                type: 'warn',
                autoHideSeconds: 'long',
                tag: 'p4-warning-cors',
            });
            api.saveOption('userjs-p4-cors-warning-displayed', '1');
        }
    }

    // From https://stackoverflow.com/a/16448981, edited for mobile support
    // Usage: $('#element').bind('mousedown touchstart', handle_mousedown);
    function handle_mousedown(e) {
        var popup = $('.p4-active-sysops > div')[0];
        if (e.target === popup || $.contains(popup, e.target)) {
            // Do not make the popup window draggable, to allow text selection etc.
            return;
        }
        var pageX0 = e.type.toLowerCase() === 'mousedown' ? e.pageX : e.originalEvent.touches[0].pageX;
        var pageY0 = e.type.toLowerCase() === 'mousedown' ? e.pageY : e.originalEvent.touches[0].pageY;
        window.my_dragging = {};
        my_dragging.pageX0 = pageX0;
        my_dragging.pageY0 = pageY0;
        my_dragging.elem = this;
        my_dragging.offset0 = $(this).offset();
        function handle_dragging(e) {
            var pageX = e.type.toLowerCase() === 'mousemove' ? e.pageX : e.originalEvent.touches[0].pageX;
            var pageY = e.type.toLowerCase() === 'mousemove' ? e.pageY : e.originalEvent.touches[0].pageY;
            var left = my_dragging.offset0.left + (pageX - my_dragging.pageX0);
            var top = my_dragging.offset0.top + (pageY - my_dragging.pageY0);
            $(my_dragging.elem)
            .offset({top: top, left: left});
        }
        function handle_mouseup(e) {
            $('body')
            .off('mousemove touchmove', handle_dragging)
            .off('mouseup touchend', handle_mouseup);
        }
        $('body')
        .on('mouseup touchend', handle_mouseup)
        .on('mousemove touchmove', handle_dragging);
    }

    // From https://stackoverflow.com/a/53800501
    // in miliseconds
    var units = {
        year  : 24 * 60 * 60 * 1000 * 365,
        month : 24 * 60 * 60 * 1000 * 365/12,
        day   : 24 * 60 * 60 * 1000,
        hour  : 60 * 60 * 1000,
        minute: 60 * 1000,
        second: 1000
    };
    var rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
    var getRelativeTime = function (d1, d2 = new Date()) {
        var elapsed = d1 - d2;
        // "Math.abs" accounts for both "past" & "future" scenarios
        for (var u in units)
            if (Math.abs(elapsed) > units[u] || u == 'second') 
                return rtf.format(Math.round(elapsed/units[u]), u);
    };

    function displaySiteInfo() {
        var gsWikiText = 'GS Loading';
        var gsWikiBGColor = GSUnknownBGColor;
        if (isGSWiki === true) {
            gsWikiText = 'GS Wiki';
            gsWikiBGColor = GSBGColor;
        } else if (isGSWiki === false) {
            gsWikiText = 'NOT GS Wiki';
            gsWikiBGColor = NonGSBGColor;
        }
        var sysopCountText = '?';
        if (sysopCount !== null) {
            sysopCountText = sysopCount.toString();
        }
        if (sysopTotal !== null) {
            sysopCountText += ('/' + sysopTotal);
        }

        popupButton.setLabel(
            '{0} ({1})'
                .replace('{0}', gsWikiText)
                .replace('{1}', sysopCountText)
        );
        $('.p4-active-sysops > .oo-ui-buttonElement-button').css({'background-color': gsWikiBGColor});

        if (sysopCount === null) return;

        $('.p4-active-sysops-loading').hide();
        $('#p4-active-sysops-list').show().empty();
        var sysop = null;
        for (var i = 0; i < sysopList.length; ++i) {
            sysop = sysopList[i];
            $('<li />', {
                html: '<a target="_blank" href="/wiki/User:{user}">{user}</a> <sup><a target="_blank" href="/wiki/Special:Contribs/{user}">C</a> · <a target="_blank" href="/wiki/Special:Logs/{user}">L</a></sup> (<a target="_blank" href="/wiki/User_talk:{user}">talk</a><sup><a target="_blank" href="/w/index.php?title=User_talk:{user}&action=edit&section=new">+</a></sup>) - active {lastActive}'
                    .replace(/{user}/g, sysop.username)
                    .replace(/{lastActive}/g, getRelativeTime(new Date(sysop.last_action)))
            }).appendTo('#p4-active-sysops-list');
        }
        if (sysopList.length < sysopCount) {
            // Show a notice for exceeding `sysopLimit`
            $('<li />', {
                html: '<i>... and {0} more active sysop(s)</i>'
                    .replace('{0}', sysopCount - sysopList.length)
            }).appendTo('#p4-active-sysops-list');
        }
    }

    function createInterface() {
        var fieldset = new OO.ui.FieldsetLayout({ 
            label: 'Config & Details'
        });
        
        var lastActionSelect = new OO.ui.ButtonSelectWidget({
            align: 'top',
            items: [
                new OO.ui.ButtonOptionWidget({
                    data: 3*86400,
                    label: '3 days'
                }),
                new OO.ui.ButtonOptionWidget({
                    data: 7*86400,
                    label: '7 days'
                }),
                new OO.ui.ButtonOptionWidget({ 
                    data: 60*86400,
                    label: '60 days'
                }),
            ]
        });
        // Default is 7 days (handled afterwards)
        //lastActionSelect.selectItem(lastActionSelect.getItemFromLabel('7 days'));

        var lastActionOptionKey = 'userjs-p4-activeSysops-lastAction';
        lastActionSelect.on('choose', function (item, selected) {
            if (item.data !== lastAction) {
                metaApi.saveOption(lastActionOptionKey, item.data.toString()).fail(displayCORSWarning);
                lastAction = item.data;
            }
            // Reset sysop count
            $('.p4-active-sysops-loading').show();
            $('#p4-active-sysops-list').hide();
            sysopCount = null;
            displaySiteInfo();
            // Then check again without cache
            checkActiveSysops(false);
            displaySiteInfo();
        });

        fieldset.addItems([
            new OO.ui.FieldLayout(
                lastActionSelect, {
                    align: 'top',
                    helpInline: true,
                    label: 'Count how many local sysops have made a logged action within ...',
                    help: 'Select again to purge cache.'
                }
            ),
            new OO.ui.FieldLayout( new OO.ui.LabelWidget( {
                label: $('<a target="_blank" id="p4-active-sysops-extlink" href="#">stewardry</a><span id="p4-active-sysops-an"> · <a target="_blank" href="#">AN</a> (<a target="_blank" href="#">edit</a>)</span><span id="p4-active-sysops-raa"> · <a target="_blank" href="#">RAA</a> (<a target="_blank" href="#">edit</a>)</span> <ul id="p4-active-sysops-list"></ul><div class="p4-active-sysops-loading"></div>')
            } ), {
                label: 'Active sysops on this wiki:',
                align: 'top'
            } ),
        ]);

        popupButton = new OO.ui.PopupButtonWidget({ 
            label: 'Loading...',
            classes: ['p4-active-sysops'],
            popup: {
                $content: fieldset.$element,
                padded: true,
                align: 'forwards',
                width: 400,
            }
        });
        
        $(document.body).append(popupButton.$element);
        // External link
        $('#p4-active-sysops-extlink').attr('href', 'https://meta.toolforge.org/stewardry/{0}?sysop=1'.replace('{0}', mw.config.get('wgDBname')));
        $('#p4-active-sysops-an').hide();
        $('#p4-active-sysops-raa').hide();
        // Get local admin noticeboard page from cookie first
        var ANTitleStorageKey = 'p4-activeSysops-ANTitle',
            ANTitleCached = mw.cookie.get(ANTitleStorageKey),
            RAATitleStorageKey = 'p4-activeSysops-RAATitle',
            RAATitleCached = mw.cookie.get(RAATitleStorageKey);
        // null -> we haven't checked yet; '' -> page does not exist
        if (ANTitleCached === null || RAATitleCached === null) {
            // If not found, fetch the info from wikidata
            wikidataApi.get({action: 'wbgetentities', ids: 'Q4580256|Q3907246', props: 'sitelinks', sitefilter: mw.config.get('wgDBname')}).done(function (data) {
                if (!data.success) return;
                if (data.entities['Q4580256'].sitelinks[mw.config.get('wgDBname')]) {
                    var ANTitle = data.entities['Q4580256'].sitelinks[mw.config.get('wgDBname')].title;
                    $('#p4-active-sysops-an > a')[0].href = '/wiki/' + ANTitle;  // View link
                    $('#p4-active-sysops-an > a')[1].href = '/w/index.php?action=edit&title=' + ANTitle;  // Edit link
                    $('#p4-active-sysops-an').show();
                }
                if (data.entities['Q3907246'].sitelinks[mw.config.get('wgDBname')]) {
                    var RAATitle = data.entities['Q3907246'].sitelinks[mw.config.get('wgDBname')].title;
                    $('#p4-active-sysops-raa > a')[0].href = '/wiki/' + RAATitle;  // View link
                    $('#p4-active-sysops-raa > a')[1].href = '/w/index.php?action=edit&title=' + RAATitle;  // Edit link
                    $('#p4-active-sysops-raa').show();
                }
                mw.cookie.set(ANTitleStorageKey, ANTitle || '', 25*3600);
                mw.cookie.set(RAATitleStorageKey, RAATitle || '', 25*3600);  // Both expire after 25h
            }).fail(displayCORSWarning);
        } else {
            if (ANTitleCached) {
                $('#p4-active-sysops-an > a')[0].href = '/wiki/' + ANTitleCached;  // View link
                $('#p4-active-sysops-an > a')[1].href = '/w/index.php?action=edit&title=' + ANTitleCached;  // Edit link
                $('#p4-active-sysops-an').show();
            }
            if (RAATitleCached) {
                $('#p4-active-sysops-raa > a')[0].href = '/wiki/' + RAATitleCached;  // View link
                $('#p4-active-sysops-raa > a')[1].href = '/w/index.php?action=edit&title=' + RAATitleCached;  // Edit link
                $('#p4-active-sysops-raa').show();
            }
        }
        $('.p4-active-sysops').hide();
        // Global CSS styles
        $('.p4-active-sysops').css({
            'z-index': 101,  // To show above .mw-portlet with z-index 100
        });
        // Loading gif CSS styles
        $('.p4-active-sysops-loading').css({
            background: "url('//upload.wikimedia.org/wikipedia/commons/d/de/Ajax-loader.gif') no-repeat center center",
            width: '32px',
            height: '32px',
            left: '50%',
            position: 'absolute',
            transform: 'translate(-50%, 0)',
        });
        // Popup Button CSS styles
        $('.p4-active-sysops > .oo-ui-buttonElement-button').css({
            'background-color': GSUnknownBGColor,
            //border: 'none',
            'border-radius': '6px',
            //color: 'black',
            display: 'inline-block',
            //'font-size': '12px',
            'text-align': 'center',
            'text-decoration': 'none',
            //height: '80px',
            //width: '80px',
        });
        // Popup window CSS styles
        $('.p4-active-sysops .oo-ui-popupWidget-popup').css({
            overflow: 'auto',
        });
        // Active sysop list CSS styles
        $('#p4-active-sysops-list').css({
            'font-size': 'smaller',
        });

        // Load global options from metawiki
        var posOptionKey = 'userjs-p4-activeSysops-buttonPos',
            posOption;
        metaApi.get({action: 'query', meta: 'userinfo', uiprop: 'options'}).done(function (data) {
            posOption = data.query.userinfo.options[posOptionKey] || null;
            if (posOption !== null) {
                var posArr = posOption.split('|'),
                    posLeft = parseFloat(posArr[0]),
                    posTop = parseFloat(posArr[1]);
                    $('.p4-active-sysops').css({top: posTop, left: posLeft, position: 'fixed'});
            } else {
                $('.p4-active-sysops').css({bottom: '10px', right: '10px', position: 'fixed'});
            }

            lastAction = data.query.userinfo.options[lastActionOptionKey] || 7*86400;
            lastAction = parseInt(lastAction);
            lastActionSelect.selectItem(lastActionSelect.findItemFromData(lastAction) || lastActionSelect.getItemFromLabel('7 days'));

            // Display after the position is decided
            $('.p4-active-sysops').show();
            checkActiveSysops();
        }).fail(function() {
            displayCORSWarning();
            // Use default configurations
            lastAction = 7*86400;
            lastActionSelect.selectItem(lastActionSelect.getItemFromLabel('7 days'));
            $('.p4-active-sysops').css({bottom: '10px', right: '10px', position: 'fixed'});
            $('.p4-active-sysops').show();
            checkActiveSysops();
        });

        // Make the button draggable
        $('.p4-active-sysops').bind('mousedown touchstart', handle_mousedown);
        $('.p4-active-sysops').bind('mouseup touchend', function (e) {
            // Save button position on metawiki so it works globally
            var posDict = $(this).position();
            metaApi.saveOption(posOptionKey, posDict.left+'|'+posDict.top).fail(displayCORSWarning);
        });
    }

    function checkGSWiki(useCache=true) {
        var storageKey = 'p4-activeSysops-isGSWiki',
            cached = mw.cookie.get(storageKey);

        if (useCache && cached !== null) {
            isGSWiki = (cached === 'true');
            displaySiteInfo();
            return;
        }

        api.get({
            action: 'query',
            list: 'wikisets',
            wsfrom: 'Opted-out of global sysop wikis',
            wsprop: 'wikisincluded',
            wslimit: 1,
        }).done(function(data) {
            var wikisincluded = data.query.wikisets[0].wikisincluded;
            isGSWiki = Object.values(wikisincluded).includes(mw.config.get('wgDBname'));
            mw.cookie.set(storageKey, isGSWiki, 25*3600);  // Expire after 25h
            displaySiteInfo();
        });
    }

    function checkActiveSysops(useCache=true) {
        var sysopListStorageKey = 'p4-activeSysops-sysopList-' + lastAction,
            sysopListCached = localStorage.getItem(sysopListStorageKey),
            sysopCountStorageKey = 'p4-activeSysops-sysopCount',
            sysopCountCached = mw.cookie.get(sysopCountStorageKey),
            sysopCountArr;

        if (useCache && sysopListCached !== null && sysopCountCached !== null) {
            sysopList = JSON.parse(sysopListCached);
            sysopCountArr = sysopCountCached.split('|');
            sysopCount = parseInt(sysopCountArr[0]);
            sysopTotal = parseInt(sysopCountArr[1]);
            displaySiteInfo();
            return;
        }

        $.ajax({
            url: '//globalcsd.toolforge.org/active-sysops/',
            data: {
                site: mw.config.get('wgDBname'),
                last_action: lastAction,
                limit: sysopLimit,
            },
            dataType: 'jsonp',
        }).done(function(data) {
            if (!data.success) {
				return;
			}
            sysopCount = data.count;
            sysopTotal = data.total;
            sysopList = data.sysops;
            //mw.cookie.set(sysopListStorageKey, JSON.stringify(sysopList), 25*3600);  // Expire after 25h
            try {
                localStorage.setItem(sysopListStorageKey, JSON.stringify(sysopList));
            } catch (e) {}
            mw.cookie.set(sysopCountStorageKey, sysopCount+'|'+sysopTotal, 25*3600);  // Expire after 25h
            displaySiteInfo();
        });
    }

    createInterface();
    checkGSWiki();
    //checkActiveSysops();  // lastAction must be determined to execute this function

} );