User:Unready/app.wlist.js

/** * 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 MW 1.25+; Use prefix constant * Version 1.2: 4 Jun 2016 *  Implement a deferred return for API queries * Version 1.3: 28 Feb 2019 *  Show all changed watches */ ((window.user = window.user || {}).app = user.app || {}).wlist = user.app.wlist || (function (mw, $) {	'use strict';

var PREFIX = 'app-wlist', 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 self = { interval: INTERVAL, maxAge: MAXAGE, message: new Date.toISOString + ' Initializing', run: run, stop: stop, version: '1.3, 28 Feb 2019' },		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_urlAPI = mw.config.get('wgScriptPath') + '/api.php', g_wArticlePath = mw.config.get('wgArticlePath'), g_semReq = newSemaphore,   // for outstanding requests g_semThread = newSemaphore, // for running threads g_list,    // revisions data     from thread 1 g_parent,  // rev IDs of parents 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_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 v = 0, self = { dec: function { return (v === 0) ? 0 : --v; },				inc: function { return ++v; },				val: function { return v;				} };

return self; }

// deferred object factory function newDeferred { var pending = true, // only the first call to accept/reject counts success = null, failure = null, result, self = { // define the success reaction then: function (f) { if (typeof f === 'function') { success = f;					} return this; // chainable },				// define the failure reaction trap: function (f) { if (typeof f === 'function') { failure = f;					} return this; // chainable },				// settle as success accept: function { if (pending) { pending = false; failure = null; if (success) { // use apply for an indefinite # of arguments result = success.apply(null, arguments); success = null; return result; }					}				},				// settle as failure reject: function { if (pending) { pending = false; success = null; if (failure) { result = failure.apply(null, arguments); failure = null; return result; }					}				}			};

return self; }

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

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

// POST an API query //  url   = protocol://host:port/path for api //  query = api parameter data object //  xhr   = xmlHttpRequest object (optional) function httpPost(url, query, xhr) { var self = newDeferred, p = Object.prototype.hasOwnProperty, s = '', i;

// make a query string from the query object for ( i in query ) { if (p.call(query, i)) { if (s.length > 0) { s += '&'; }				s += i + '=' + encodeURIComponent(query[i]); }		}		// create a new xhr, if needed if (!(xhr instanceof XMLHttpRequest)) { xhr = new XMLHttpRequest; }		// post the request asynchronously xhr.open('POST', url, true); xhr.setRequestHeader('Content-Type',			'application/x-www-form-urlencoded;'); xhr.onreadystatechange = function { if (xhr.readyState === 4) { xhr.onreadystatechange = null; if (xhr.status === 200) { self.accept(xhr); } else { self.reject(xhr); }			}		};		xhr.send(s); // caller gets a deferred interface back return self; }

// 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])$'			), ipv6 = new RegExp( // MediaWiki always expands ::				'^(?:(?:[1-9a-f][0-9a-f]{0,3}|0):){7}' +						'(?:[1-9a-f][0-9a-f]{0,3}|0)$',				'i'			);

var retVal;

if (!ipv4.test(userRaw) && !ipv6.test(userRaw)) { // registered user retVal = userRaw.replace(/ /g, '_'); retVal = String.prototype.concat(				'',					userRaw,				'',				' (', '', 'Talk', '', ' | ',				'', 'contribs', ')'			); } else { // anonymous user retVal = String.prototype.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.prototype.concat(				' ',					'(+', retVal.toString, ')',				' '			); } else if (retVal < 0) { retVal = String.prototype.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.prototype.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.prototype.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 bot property into list by matching users // merge change property into list by matching titles i = 0; while ( i < g_list.length ) { if (g_bots.indexOf(g_list[i].user) !== -1) { g_list[i].bot = ''; // flag the bot }			if (g_changed.indexOf(g_list[i].title) !== -1) { g_list[i].changed = ''; // flag the change ++i; } else if (g_list[i].timestamp > g_isoFrom) { ++i; } else { g_list.splice(i, 1); // old watch & already read }		}		// 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); }

// --- start of thread 3 - bot users --- // process list=users & usprop=groups return // possibly multiple times //  xhr = xmlHttpRequest object function onGroups(xhr) { var o, a, i;

if (g_cancel) { return; }		o = JSON.parse(xhr.responseText); if (o.error !== undefined) { self.message += '\n' + new Date.toISOString + ' onGroups :: ' + o.error.code + ': ' + o.error.info; stop; g_jStatMsg.text('onGroups :: XMLHttpRequest error'); return; }		if ((o.query === undefined) || (o.query.users === undefined)) { self.message += '\n' + new Date.toISOString + ' onGroups :: ' + xhr.responseText; stop; g_jStatMsg.text('onGroups :: Query ended abnormally.'); 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(xhr); // keep going until no more users }

// request list=users & usprop=groups from the api for the users //  xhr = xmlHttpRequest object (optional) function reqGroups(xhr) { var query = { format: 'json', action: 'query', list: 'users', usprop: 'groups' };

if (g_users.length > 0) { query.ususers = 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 g_semReq.inc; httpPost(g_urlAPI, query, xhr) .then(function (xhr) {					g_semReq.dec;					onGroups(xhr);				}) .trap(function (xhr) {					g_semReq.dec;					self.message += '\n' + new Date.toISOString +						' reqGroups :: ' + xhr.statusText;					stop;					g_jStatMsg.text('reqGroups :: API failed');				}); } else { g_semThread.dec; // release part of the merge lock mergeThreads; }	}	// --- end of thread 3 ---

// --- start of thread 2 - changed articles --- // process list=watchlistraw & wrshow=changed return // possibly multiple times if continuation //  xhr = xmlHttpRequest object function onChanged(xhr) { var o, a, i;

if (g_cancel) { return; }		o = JSON.parse(xhr.responseText); if (o.error !== undefined) { self.message += '\n' + new Date.toISOString + ' onChanged :: ' + o.error.code + ': ' + o.error.info; stop; g_jStatMsg.text('onChanged :: XMLHttpRequest error'); return; }		if (o.watchlistraw === undefined) { self.message += '\n' + new Date.toISOString + ' onChanged :: ' + xhr.responseText; stop; g_jStatMsg.text('onChanged :: Query ended abnormally.'); 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(xhr, o); return; }		g_semThread.dec; // release part of the merge lock mergeThreads; }

// get info to flag unread revisions // request list=watchlistraw & wrshow=changed //  xhr = xmlHttpRequest object (optional) //  c   = continuation object (optional) function reqChanged(xhr, c) { var query = { format: 'json', action: 'query', list: 'watchlistraw', wrlimit: MAXRES, wrshow: 'changed' },			i;

if (!(xhr instanceof XMLHttpRequest)) { c = xhr; xhr = undefined; }		if (c !== undefined) { for ( i in c ) { if (c.hasOwnProperty(i)) { query[i] = c[i]; }			}		}		// returns only revisions which are unread // watchlistraw: array -> title: string g_semReq.inc; httpPost(g_urlAPI, query, xhr) .then(function (xhr) {				g_semReq.dec;				onChanged(xhr);			}) .trap(function (xhr) {				g_semReq.dec;				self.message += '\n' + new Date.toISOString +					' reqChanged :: ' + xhr.statusText;				stop;				g_jStatMsg.text('reqChanged :: API failed');			}); }	// --- end of thread 2 ---

// --- start of thread 1 - article revisions and parent revisions --- // process prop=revisions return for the parents // possibly multiple times //  xhr = xmlHttpRequest object function onParentRevs(xhr) { var o, a,			i, j,			found;

if (g_cancel) { return; }		o = JSON.parse(xhr.responseText); if (o.error !== undefined) { self.message += '\n' + new Date.toISOString + ' onParentRevs :: ' + o.error.code + ': ' + o.error.info; stop; g_jStatMsg.text('onParentRevs :: XMLHttpRequest error'); return; }		if (!$.isArray(o)) { // empty result set is Object([]) if ((o.query === undefined) || (o.query.pages === undefined)) { self.message += '\n' + new Date.toISOString + ' onParentRevs :: ' + xhr.responseText; stop; g_jStatMsg.text('onParentRevs :: Query ended abnormally.'); 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(xhr); // keep going until no more parents }

// request prop=revisions from the api for the parents //  xhr = xmlHttpRequest object (optional) function reqParentRevs(xhr) { var query = { format: 'json', action: 'query', prop: 'revisions', rvprop: 'size', };

if (g_parent.length > 0) { query.revids = g_parent.slice(0, MAXREQ).join('|'); g_parent = g_parent.slice(MAXREQ); g_semReq.inc; httpPost(g_urlAPI, query, xhr) .then(function (xhr) {					g_semReq.dec;					onParentRevs(xhr);				}) .trap(function (xhr) {					g_semReq.dec;					self.message += '\n' + new Date.toISOString +						' reqParentRevs :: ' + xhr.statusText;					stop;					g_jStatMsg.text('reqParentRevs :: API failed');				}); } else { g_semThread.dec; // release part of the merge lock mergeThreads; }	}

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

if (g_cancel) { return; }		o = JSON.parse(xhr.responseText); if (o.error !== undefined) { self.message += '\n' + new Date.toISOString + ' onCurrentRevs :: ' + o.error.code + ': ' + o.error.info; stop; g_jStatMsg.text('onCurrentRevs :: XMLHttpRequest error'); return; }		if (!$.isArray(o)) { // empty result set is Object([]) if ((o.query === undefined) || (o.query.pages === undefined)) { self.message += '\n' + new Date.toISOString + ' onCurrentRevs :: ' + xhr.responseText; stop; g_jStatMsg.text('onCurrentRevs :: Query ended abnormally.'); 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].title = a[i].title; g_list.push(a[i].revisions[0]); }			}			// find the continuation data, if it exists // continue is a reserved word, so quote it			o = o['continue'] || ((o = o['query-continue']) && o.watchlistraw); if (o !== undefined) { // get more list items reqCurrentRevs(xhr, 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) { g_parent.push(g_list[i].parentid); }			if (g_users.indexOf(g_list[i].user) === -1) { g_users.push(g_list[i].user); }		}		reqParentRevs(xhr); // continue thread 1, reuse xhr g_bots = [];       // init thread 3 output shared area g_semThread.inc; // semaphore for thread 3 reqGroups;       // fork thread 3 }

// request prop=revisions from the api //  xhr = xmlHttpRequest object (optional) //  c   = continuation object (optional) function reqCurrentRevs(xhr, c) { var query = { format: 'json', action: 'query', prop: 'revisions', rvprop: 'ids|flags|user|size|timestamp|parsedcomment', generator: 'watchlistraw', gwrlimit: MAXRES },			i;

if (!(xhr instanceof XMLHttpRequest)) { c = xhr; xhr = undefined; }		if (c !== undefined) { for ( i in c ) { if (c.hasOwnProperty(i)) { query[i] = 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} g_semReq.inc; httpPost(g_urlAPI, query, xhr) .then(function (xhr) {				g_semReq.dec;				onCurrentRevs(xhr);			}) .trap(function (xhr) {				g_semReq.dec;				self.message += '\n' + new Date.toISOString +					' reqCurrentRevs :: ' + xhr.statusText;				// don't clear the thread semaphore				//  because the error should block				// but uncheck the control box				//   because the error stops the refresh				stop;				g_jStatMsg.text('reqCurrentRevs :: API failed');			}); }	// --- end of thread 1 ---

// 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 g_list = [];      // init thread 1 shared areas g_users = []; g_parent = []; g_semThread.inc; // semaphore for thread 1 reqCurrentRevs; // start thread 1 g_changed = [];   // init thread 2 shared area g_semThread.inc; // semaphore for thread 2 reqChanged;     // start 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 { while (g_semThread.dec > 0); // reset all threads self.message = new Date.toISOString + ' 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; self.message += '\n' + new Date.toISOString + ' Stopped'; 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.prototype.concat(				' ',				'<p class="' + PREFIX + '-stat">',					' ',					' Refresh: ',					'  ',				' ',				'  '			)),			jWrapper = $('#' + PREFIX);

// abort if not one element if (jWrapper.length !== 1) { self.message += '\n' + new Date.toISOString + ' 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 self.message += '\n' + new Date.toISOString + ' 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 self; }(mediaWiki, jQuery));