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 07:05, 5 March 2016 (Version 1.1.1: Bugfix; Tabify). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
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.
// __NOINDEX__
/**
 * 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.1, 5 Mar 2016'
		},
		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
			{
				if (g_semThread.val() > 0)
				{
					while (g_semThread.dec() > 0); // reset all threads
				}
				g_self.message = 'OK';
				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;
		}
		// OK to run
		g_hTimeout = 0;
		run();
	});

	return g_self;
}(mediaWiki, jQuery));