Jump to content

User:Unready/app.wlist.js

From Wikipedia, the free encyclopedia
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 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(
				'<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.prototype.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.prototype.concat(
				'<span class="mw-plusminus-pos">',
					'(+', retVal.toString(), ')',
				'</span>'
			);
		} else if (retVal < 0) {
			retVal = String.prototype.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></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.prototype.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.prototype.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 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></p>',
				'<p class="' + PREFIX + '-stat">',
					'<input type="checkbox"/>',
					' Refresh: ',
					'<span></span>',
				'</p>',
				'<div></div>'
			)),
			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));