Jump to content

User:Unready/app.wlist.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Unready (talk | contribs) at 14:30, 21 November 2015 (Version 1.1: Expand continuation support for WikiMedia; Use prefix constant). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
(diff) ← Previous revision | Latest revision (diff) | Newer revision → (diff)
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * implement something like Special:Watchlist in JavaScript
 *
 * look for a DOM element with the ID "app-wlist"
 * 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
 *   Original version for Wikia
 * Version 1.1: 21 Nov 2015
 *   Expand continuation support for WikiMedia; Use prefix constant
 */
((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
      PREFIX = 'app-wlist';

  var g_self =
      {
        interval: INTERVAL,
        maxAge: MAXAGE,
        message: 'Initializing',
        run: run,
        stop: stop,
        version: '1.1, 21 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
      (
        '<a href="', encodeURI(
          g_wArticlePath.replace('$1', 'User:') + retVal), '">',
          userRaw,
        '</a>',
        '&nbsp;(',
        '<a href="', encodeURI(
          g_wArticlePath.replace('$1', 'User_talk:') + retVal), '">',
          'Talk',
        '</a>',
        '&nbsp;|&nbsp;',
        '<a href="', encodeURI(
          g_wArticlePath.replace('$1', 'Special:Contributions/') +
          retVal), '">',
          'contribs',
        '</a>)'
      );
    }
    else
    { // anonymous user
      retVal = String.concat
      (
        '<a href="', encodeURI(
          g_wArticlePath.replace('$1', 'Special:Contributions/') +
          userRaw), '">',
          userRaw,
        '</a>',
        '&nbsp;(',
        '<a href="', encodeURI(
          g_wArticlePath.replace('$1', 'User_talk:') + userRaw), '">',
          'Talk',
        '</a>)'
      );
    }
    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
      (
        '<span class="mw-plusminus-pos">',
          '(+', retVal.toString(), ')',
        '</span>'
      );
    }
    else if (retVal < 0)
    {
      retVal = String.concat
      (
        '<span class="mw-plusminus-neg">',
          '(', retVal.toString(), ')',
        '</span>'
      );
    }
    else // size = 0
    {
      retVal = '<span class="mw-plusminus-null">(0)</span>';
    }
    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 = $('<table><tbody/></table>');
    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
        (
          '<tr><td colspan="2"><h4>',
            dateDMY,
          '</h4></td></tr>'
        ));
      }
      // 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
      (
        '<tr class="' + PREFIX + '-data">',
          '<td>',
            date[1].replace('Z', ''), '&nbsp;',
            '<span>',
              (g_list[i].parentid === 0 ? 'N' : '.'),
              (g_list[i].minor !== undefined ? 'm' : '.'),
              (g_list[i].bot !== undefined ? 'b' : '.'),
              (g_list[i].changed !== undefined ? 'c' : '.'),
            '</span>',
          '</td>',
          '<td>',
            '<a href="', url, '">',
              g_list[i].title,
            '</a>',
            ' (',
            (g_list[i].parentid !== 0 ?
              '<a href="' + url + '?diff=' + g_list[i].revid + '">' +
                'diff' +
              '</a>' +
              '&nbsp;|&nbsp;' :
              ''),
            '<a href="', url, '?action=history">',
              'hist',
            '</a>',
            ')',
            size,
            user,
            (g_list[i].parsedcomment.length > 0 ?
              ' (' + g_list[i].parsedcomment + ')' :
              ''),
          '</td>',
        '</tr>'
      ));
    }
    // 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 =
          [
            'format=json',
            'action=query',
            '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 (invalid|missing: 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);
      }
      // find the continuation data, if it exists
      o = o.continue || ((o = o['query-continue']) && o.watchlistraw);
      if (o !== undefined)
      { // get more list items
        reqChanged(o);
        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(c)
    {
      var action =
          [
            'format=json',
            'action=query',
            'list=watchlistraw',
            'wrlimit=' + MAXRES,
            'wrshow=changed'
          ].join('&'),
          i;

      if (c !== undefined)
      {
        for (i in c)
        {
          if (c.hasOwnProperty(i))
          {
            action += '&' + i + '=' + encodeURIComponent(c[i]);
          }
        }
      }
      // 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 =
          [
            'format=json',
            'action=query',
            '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]);
          }
        }
        // find the continuation data, if it exists
        o = o.continue || ((o = o['query-continue']) && o.watchlistraw);
        if (o !== undefined)
        { // get more list items
          reqCurrentRevs(o);
          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(c)
    {
      var rvprop = 'ids|flags|user|size|timestamp|parsedcomment',
          action =
          [
            'format=json',
            'action=query',
            'prop=revisions',
            'rvprop=' + encodeURIComponent(rvprop),
            'generator=watchlistraw',
            'gwrlimit=' + MAXRES
          ].join('&'),
          i;

      if (c !== undefined)
      {
        for (i in c)
        {
          if (c.hasOwnProperty(i))
          {
            action += '&' + i + '=' + encodeURIComponent(c[i]);
          }
        }
      }
      // 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/>',
          '<p class="' + PREFIX + '-stat">',
            '<input type="checkbox"/>',
            ' Refresh: ',
            '<span/>',
          '</p>',
          '<div/>'
        )),
        jWrapper = $('#' + PREFIX);

    // 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));