User:Saftzie/app.wlist.js

// __NOINDEX__ /* * implement something like Special:Watchlist in JavaScript * * look for a DOM element with the ID "watchlist" * set its inner HTML to a list of most recent changes *  sorted by time in descending order * name the module not to collide with Object.prototype.watch * * Version 1.0: 18 Nov 2015 */ ((window.user = window.user || {}).app = window.user.app || {}).wlist = (function (mw, $) { 'use strict';

var MAXAGE = 168,  // default max revision age (hours) INTERVAL = 600, // default refresh interval (seconds) MAXRES = 500,  // maximum # of results per request MAXREQ = 50;   // maximum # of inputs per request

var g_self = {       interval: INTERVAL, maxAge: MAXAGE, message: 'Initializing', run: run, stop: stop, version: '1.0, 18 Nov 2015' },     g_hTimeout = -1,  // cannot run = -1; okay to run = 0; running > 0 g_cancel = false, // refresh has been canceled g_epoch = 0,     // epoch second for next refresh g_wServer = mw.config.get('wgServer'), g_wScriptPath = mw.config.get('wgScriptPath'), g_wArticlePath = mw.config.get('wgArticlePath'), g_list,    // revisions data from thread 1 g_changed, // unread changes from thread 2 g_users,   // list of users  from thread 1 for thread 3 g_bots,    // list of bots   from thread 3 g_semReq = newSemaphore,   // for outstanding requests g_semThread = newSemaphore, // for running threads g_txtTime, // "now" string g_isoFrom, // discard revisions prior g_jTimeMsg, // changes-since message g_jBox,    // on-screen run/stop control g_jStatMsg, // on-screen message g_jList;   // the watchlist

// counting semaphore factory function newSemaphore {   var value = 0, self;

self = {     dec: function {       return --value; },     inc: function {       return ++value; },     val: function {       return value; }   };    return self; }

// get interval (sec) from module properties function getInterval {   if ((typeof g_self.interval !== 'number') ||      (g_self.interval < 60) ||   // 1 minute      (g_self.interval > 7200 ))  // 2 hours {     g_self.interval = INTERVAL; // reset to default if insane }   else {     g_self.interval = Math.floor(g_self.interval); }   return g_self.interval; }

// get maxAge (hour) from module properties function getMaxAge {   if ((typeof g_self.maxAge !== 'number') ||      (g_self.maxAge < 2) ||      (g_self.maxAge > 8784)) // 366 days {     g_self.maxAge = MAXAGE; // reset to default if insane }   else {     g_self.maxAge = Math.floor(g_self.maxAge); }   return g_self.maxAge; }

// call the MediaWiki API using POST //  req       = xmlHttpRequest object //  action    = api parameters //  next      = callback on success //  semaphore = count of outstanding requests function apiCall(req, action, next, semaphore) {   var urlAPI = g_wServer + g_wScriptPath + '/api.php';

req.open('POST', urlAPI, true); req.setRequestHeader('Content-Type',     'application/x-www-form-urlencoded;'); req.onreadystatechange = function {     if (req.readyState === 4) {       semaphore.dec; req.onreadystatechange = null; if (req.status === 200) {         next; }       else {         g_self.message = 'apiCall :: "' +            action + '" returned "' + req.statusText + '"'; }     }    };    semaphore.inc; req.send(action); }

// make DOM A tags for user //  including talk and contrib links // userRaw = rev user, possibly with spaces function aUser(userRaw) {   var ipv4 = new RegExp (         '^(?:(?:[1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}' +              '(?:[1-9]?[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'        ),        ipv6pos = new RegExp // ipv6 should pass pos and fail neg (         '^(?:(?:[1-9a-f][\da-f]{0,3}|0?):){2,7}' +              '(?:[1-9a-f][\da-f]{0,3}|0?)$',          'i'        ), ipv6neg = new RegExp (         '(?:^(?:(?:[1-9a-f][\da-f]{0,3}|0):){1,6}' + '(?:[1-9a-f][\da-f]{0,3}|0)$|' + '::.*::|' +            ':::|' +             '^:[^:]|' +             '[^:]:$)',          'i'        );

var retVal;

if (!ipv4.test(userRaw) &&     (!ipv6pos.test(userRaw) || ipv6neg.test(userRaw))) { // registered user retVal = userRaw.replace(/ /g, '_'); retVal = String.concat (       '',          userRaw,        '',        ' (', '', 'Talk', '', ' | ',       '', 'contribs', ')'     ); }   else { // anonymous user retVal = String.concat (       '',          userRaw,        '',        ' (', '', 'Talk', ')'     ); }   return retVal; }

// make a DOM SPAN tag for the size change //  including font color // revData = single rev list data entry function spanSize(revEntry) {   var retVal = (revEntry.parentid !== 0 ?          revEntry.size - revEntry.parentsize :          revEntry.size);

if (retVal > 0) {     retVal = String.concat (       ' ',          '(+', retVal.toString, ')',        ' '      ); }   else if (retVal < 0) {     retVal = String.concat (       ' ',          '(', retVal.toString, ')',        ' '      ); }   else // size = 0 {     retVal = ' (0) '; }   return retVal; }

// format the watch list rev data for human consumption function processList {   var jTable, url, user, size, date, dateDMY, dateLast = '', i;

g_list.sort(function (a, b)   { // sort descending by ISO date      return (a.timestamp < b.timestamp ? 1 : -1);   });    // make a table with all the data jTable = $(' '); for ( i = 0 ; i < g_list.length ; ++i ) {     date = g_list[i].timestamp.split('T'); // new date group ? if (date[0] !== dateLast) {       dateLast = date[0]; dateDMY = new Date(dateLast).toUTCString .substr(5, 11).replace(/^0/g, ''); jTable.find('tbody').append(String.concat       ( ' ',           dateDMY, ' '        ));      }      // article base url url = encodeURI(       g_wArticlePath.replace('$1', g_list[i].title.replace(/ /g, '_'))); // user A tag user = aUser(g_list[i].user); // size change size = '. ' + spanSize(g_list[i]) + '. ';     // make a new row jTable.find('tbody').append(String.concat     ( '', ' ',           date[1].replace('Z', ''), ' ', ' ',             (g_list[i].parentid === 0 ? 'N' : '.'), (g_list[i].minor !== undefined ? 'm' : '.'), (g_list[i].bot !== undefined ? 'b' : '.'), (g_list[i].changed !== undefined ? 'c' : '.'), ' ',         ' ',          ' ',            '', g_list[i].title, '', ' (',           (g_list[i].parentid !== 0 ? '' + 'diff' + '' + ' | ' :             ''),            '',              'hist',            '</a>',            ')', size, user, (g_list[i].parsedcomment.length > 0 ?             ' (' + g_list[i].parsedcomment + ')' :              ''), ' ',       ' '      ));    }    // insert the info into the dom g_jTimeMsg.text(g_txtTime); g_jList.empty.append(jTable); }

// merge data from threads function mergeThreads {   var i;

if (g_semThread.val !== 0) {     return; // lock progress until all threads complete }   // merge change property into list by matching titles // merge bot property into list by matching users for ( i = 0 ; i < g_list.length ; ++i ) {     if (g_changed.indexOf(g_list[i].title) !== -1) {       g_list[i].changed = ''; // flag the change }     if (g_bots.indexOf(g_list[i].user) !== -1) {       g_list[i].bot = ''; // flag the bot }   }    // display the data // calculate the epoch for the next refresh // force an immediate timeout to schedule it   processList; g_epoch = Math.floor(new Date.getTime / 1000) + getInterval; g_hTimeout = window.setTimeout(onTimeout, 0); }

// thread 3 - bot users function queryListUsers {   var req = new XMLHttpRequest;

// process list=users & usprop=groups return // possibly multiple times function onGroups {     var o, a, i;

if (g_cancel) {       return; }     o = JSON.parse(req.responseText); if (o.error !== undefined) {       g_self.message = 'onGroups :: ' + o.error.code + ': ' + o.error.info; g_jStatMsg.text('onGroups :: XMLHttpRequest error'); g_jBox .prop('disabled', true) .prop('checked', false); return; }     if ((o.query === undefined) || (o.query.users === undefined)) {       g_self.message = 'onGroups :: ' + req.responseText; g_jStatMsg.text('onGroups :: Query ended abnormally.'); g_jBox .prop('disabled', true) .prop('checked', false); return; }     a = o.query.users; // if groups include bot, save user name for ( i = 0 ; i < a.length ; ++i ) {       if ((a[i].groups !== undefined) && (a[i].groups.indexOf('bot') !== -1)) {         g_bots.push(a[i].name); }     }      reqGroups; // keep going until no more users }

// request list=users & usprop=groups from the api for the users function reqGroups {     var action = [           'action=query', 'format=json', 'list=users', 'usprop=groups', 'ususers=' ].join('&');

if (g_users.length > 0) {       action += encodeURIComponent(g_users.slice(0, MAXREQ).join('|')); g_users = g_users.slice(MAXREQ); // query -> users: array //  -> groups: array (or invalid: string, if not a user) //  -> strings apiCall(req, action, onGroups, g_semReq); }     else {       g_semThread.dec; // release part of the merge lock mergeThreads; }   }

g_bots = [];      // init thread 3 output shared area g_semThread.inc; // semaphore for thread 3 reqGroups; }

// thread 2 - changed articles function queryListWatchlist {   var req = new XMLHttpRequest;

// process list=watchlistraw & wrshow=changed return // possibly multiple times if continuation function onChanged {     var o, a, i;

if (g_cancel) {       return; }     o = JSON.parse(req.responseText); if (o.error !== undefined) {       g_self.message = 'onChanged :: ' + o.error.code + ': ' + o.error.info; g_jStatMsg.text('onChanged :: XMLHttpRequest error'); return; }     if (o.watchlistraw === undefined) {       g_self.message = 'onChanged :: ' + req.responseText; g_jStatMsg.text('onChanged :: Query ended abnormally.'); g_jBox .prop('disabled', true) .prop('checked', false); return; }     a = o.watchlistraw; // query returns only changed articles, so save the title for ( i = 0 ; i < a.length ; ++i ) {       g_changed.push(a[i].title); }     if (o['query-continue'] !== undefined) { // get more list items reqChanged(o['query-continue'].watchlistraw.wrcontinue); return; }     g_semThread.dec; // release part of the merge lock mergeThreads; }

// get info to flag unread revisions // request list=watchlistraw & wrshow=changed // optional parameter is for continuation function reqChanged(wrcontinue) {     var action = [           'action=query', 'format=json', 'list=watchlistraw', 'wrlimit=' + MAXRES, 'wrshow=changed' ].join('&');

if (wrcontinue !== undefined) {       action += '&wrcontinue=' + encodeURIComponent(wrcontinue); }     // returns only revisions which are unread // watchlistraw: array -> title: string apiCall(req, action, onChanged, g_semReq); }

g_changed = [];   // init thread 2 output shared area g_semThread.inc; // semaphore for thread 2 reqChanged; }

// thread 1 - article revisions and parent revisions function queryPropRevisions {   var req = new XMLHttpRequest, parent = []; // rev IDs of parents

// process prop=revisions return for the parents // possibly multiple times function onParentRevs {     var o, a,          i, j,          found;

if (g_cancel) {       return; }     o = JSON.parse(req.responseText); if (o.error !== undefined) {       g_self.message = 'onParentRevs :: ' + o.error.code + ': ' + o.error.info; g_jStatMsg.text('onParentRevs :: XMLHttpRequest error'); g_jBox .prop('disabled', true) .prop('checked', false); return; }     if (!$.isArray(o)) // empty result set is Object([]) {       if ((o.query === undefined) || (o.query.pages === undefined)) {         g_self.message = 'onParentRevs :: ' + req.responseText; g_jStatMsg.text('onParentRevs :: Query ended abnormally.'); g_jBox .prop('disabled', true) .prop('checked', false); return; }       a = o.query.pages; // look for a title match, then set the parent size for ( i in a ) {         if (a[i].title !== undefined) {           found = false; for ( j = 0 ; !found && (j < g_list.length) ; ++j ) {             found = (a[i].title === g_list[j].title); if (found) {               g_list[j].parentsize = a[i].revisions[0].size; }           }          }        }      }      reqParentRevs; // keep going until no more parents }

// request prop=revisions from the api for the parents function reqParentRevs {     var action = [           'action=query', 'format=json', 'prop=revisions', 'rvprop=size', 'revids=' ].join('&');

if (parent.length > 0) {       action += encodeURIComponent(parent.slice(0, MAXREQ).join('|')); parent = parent.slice(MAXREQ); apiCall(req, action, onParentRevs, g_semReq); }     else {       g_semThread.dec; // release part of the merge lock mergeThreads; }   }

// process prop=revisions return // possibly multiple times if continuation function onCurrentRevs {     var o, a, i;

if (g_cancel) {       return; }     o = JSON.parse(req.responseText); if (o.error !== undefined) {       g_self.message = 'onCurrentRevs :: ' + o.error.code + ': ' + o.error.info; g_jStatMsg.text('onCurrentRevs :: XMLHttpRequest error'); g_jBox .prop('disabled', true) .prop('checked', false); return; }     if (!$.isArray(o)) // empty result set is Object([]) {       if ((o.query === undefined) || (o.query.pages === undefined)) {         g_self.message = 'onCurrentRevs :: ' + req.responseText; g_jStatMsg.text('onCurrentRevs :: Query ended abnormally.'); g_jBox .prop('disabled', true) .prop('checked', false); return; }       a = o.query.pages; // save revision data, if it exists for ( i in a ) {         if ((a[i].revisions !== undefined) &&            (a[i].revisions[0] !== undefined) &&            (a[i].revisions[0].timestamp > g_isoFrom)) {           a[i].revisions[0].title = a[i].title; g_list.push(a[i].revisions[0]); }       }        if (o['query-continue'] !== undefined) { // get more list items reqCurrentRevs(o['query-continue'].watchlistraw.gwrcontinue); return; }     }      // collect the parent IDs to get their sizes // collect users to get their groups for ( i = 0 ; i < g_list.length ; ++i ) {       if (g_list[i].parentid !== 0) {         parent.push(g_list[i].parentid); }       if (g_users.indexOf(g_list[i].user) === -1) {         g_users.push(g_list[i].user); }     }      reqParentRevs;  // continue thread 1 queryListUsers; // fork thread 3 }

// request prop=revisions from the api // optional parameter is for continuation function reqCurrentRevs(gwrcontinue) {     var rvprop = 'ids|flags|user|size|timestamp|parsedcomment', action = [           'action=query', 'format=json', 'prop=revisions', 'rvprop=' + encodeURIComponent(rvprop), 'generator=watchlistraw', 'gwrlimit=' + MAXRES ].join('&');

if (gwrcontinue !== undefined) {       action += '&gwrcontinue=' + encodeURIComponent(gwrcontinue); }     // rvprop = (ids, flags (minor), user, timestamp, comment) //  is the default // query -> pages -> {(pageid), (pageid), (pageid), ...} //  -> revisions: array (or missing: string, if no revisions) //  -> {revid: number, parentid: number, minor: string, user: string, //       size: number, timestamp: string, parsedcomment: string} apiCall(req, action, onCurrentRevs, g_semReq); }

g_list = [];      // init thread 1 output shared areas g_users = []; g_semThread.inc; // semaphore for thread 1 reqCurrentRevs; }

// process timeout events function onTimeout {   var d = new Date, countdown = g_epoch - Math.floor(d.getTime / 1000), maxAge = getMaxAge;

if (g_cancel) {     return; }   if (countdown < 1) {     // create a current time string to use later // put a comma after the year and add some text g_txtTime = 'Changes in the ' + maxAge + ' hours preceding ' + d.toUTCString .replace(/(\d{4})/, '$1,') .replace('GMT', '(UTC)') .replace(/ 0/g, ' '); // date in msec; max age in hours d.setTime(d.getTime - maxAge * 3600000); g_isoFrom = d.toISOString; g_jStatMsg.text('now...'); // start the threads queryPropRevisions; // thread 1 queryListWatchlist; // thread 2 }   else {     // count down one more second g_hTimeout = window.setTimeout(onTimeout, 1100 - d.getMilliseconds); g_jStatMsg.text('in ' + countdown + ' seconds'); } }

// for run/stop, each event handler, //  including onTimeout, //  but excluding interactive controls, //  should begin //    if (g_cancel) {return;}

// start the refresh, if it's stopped // refuse to start if there are outstanding requests function run {   if (g_hTimeout === 0) {     if (g_semReq.val > 0) {       g_jStatMsg.text('cannot start with requests outstanding'); g_jBox.prop('checked', false); }     else {       g_cancel = false; g_epoch = 0; g_hTimeout = window.setTimeout(onTimeout, 0); g_jBox.prop('checked', true); }   }  }

// stop the refresh, if it's running // outstanding requests must be handled in run function stop {   if (g_hTimeout > 0) { // try to stop the next refresh, although it may already be too late window.clearTimeout(g_hTimeout); g_hTimeout = 0; g_cancel = true; g_jStatMsg.text('stopped'); g_jBox.prop('checked', false); }   g_jBox.prop('checked', false); }

// handle click events on the checkbox function onClick {   if (g_jBox.prop('checked')) {     run; }   else {     stop; } }

$(function main {    var jContent = $(String.concat (         ' ',          '<p class="app-wlist-stat">',            ' ',            ' Refresh: ',            ' ',          ' ',          ' '        )),        jWrapper = $('#app-wlist');

// abort if not one element if (jWrapper.length !== 1) {     g_self.message = 'main :: incorrect watchlist elements'; return; }   // insert content into the wrapper jWrapper.empty.append(jContent); g_jTimeMsg = jContent.filter(':first'); g_jBox = jContent.find('input'); g_jBox.click(onClick); g_jStatMsg = jContent.find('span'); g_jList = jContent.filter(':last'); // abort if unable to make request objects if (window.XMLHttpRequest === undefined) { // IE 6 and previous, maybe others g_self.message = 'main :: Unable to create XMLHttpRequest'; g_jStatMsg.text('Request creation failed'); g_jBox.prop('disabled', true); return; }   // start with an immediate timeout g_self.message = 'OK'; g_jBox.prop('checked', true); g_hTimeout = window.setTimeout(onTimeout, 0); });

return g_self; }(mediaWiki, jQuery));