Jump to content

User:Unready/app.wlist.js

From Wikipedia, the free encyclopedia
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
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));