MediaWiki:Gadget-rtrc.js

/** * Real-Time Recent Changes * https://github.com/Krinkle/mw-gadget-rtrc * * @author Timo Tijhof * @license https://krinkle.mit-license.org/@2016 */ /*global alert */ (function ($, mw) {	'use strict';

/**	 * Configuration * -	 */	var appVersion = 'v1.3.0', conf = mw.config.get([		'skin',		'wgAction',		'wgCanonicalSpecialPageName',		'wgPageName',		'wgServer',		'wgTitle',		'wgUserLanguage',		'wgDBname',		'wgScriptPath'	]), // Can't use mw.util.wikiScript until after #init apiUrl = conf.wgScriptPath + '/api.php', cvnApiUrl = '//cvn.wmflabs.org/api.php', oresApiUrl = '//ores.wikimedia.org/scores/' + conf.wgDBname + '/', oresModel = false, intuitionLoadUrl = '//tools.wmflabs.org/intuition/load.php?env=mw', docUrl = '//meta.wikimedia.org/wiki/User:Krinkle/Tools/Real-Time_Recent_Changes?uselang=' + conf.wgUserLanguage, // 32x32px ajaxLoaderUrl = '//upload.wikimedia.org/wikipedia/commons/d/de/Ajax-loader.gif', annotationsCache = { patrolled: {}, cvn: {}, ores: {} },	// See annotationsCacheUp annotationsCacheSize = 0,

/**	 * Info from the wiki * -	 */	userHasPatrolRight = false, rcTags = [], wikiTimeOffset,

/**	 * State * -	 */	updateFeedTimeout,

rcDayHeadPrev, skippedRCIDs = [], monthNames,

prevFeedHtml, updateReq,

/**	 * Feed options * -	 */	defOpt = { rc: { // Timestamp start: undefined, // Timestamp end: undefined, // Direction "older" (descending) or "newer" (ascending) dir: 'older', // Array of namespace ids namespace: undefined, // User name user: undefined, // Tag ID			tag: undefined, // Filters hideliu: false, hidebots: true, unpatrolled: false, limit: 25, // Type filters are "show matches only" typeEdit: true, typeNew: true },

app: { refresh: 5, cvnDB: false, ores: false, massPatrol: false, autoDiff: false }	},	aliasOpt = { // Back-compat for v1.0.4 and earlier showAnonOnly: 'hideliu', showUnpatrolledOnly: 'unpatrolled' },	opt = $(true, {}, defOpt),

timeUtil, message, msg, navSupported = conf.skin === 'vector', rAF = window.requestAnimationFrame || setTimeout,

currentDiff, currentDiffRcid, $wrapper, $body, $feed, $RCOptionsSubmit;

/**	 * Utility functions * -	 */

/**	 * Prepend a leading zero if value is under 10 *	 * @param {number} num Value between 0 and 99. * @return {string} */	function pad(num) { return (num < 10 ? '0' : '') + num; }

timeUtil = { // Create new Date object from an ISO-8601 formatted timestamp, as // returned by the MediaWiki API (e.g. "2010-04-25T23:24:02Z") newDateFromISO: function (s) { return new Date(Date.parse(s)); },

/**		 * Apply user offset *		 * Only use this if you're extracting individual values from the object (e.g. getUTCDay or		 * getUTCMinutes). The internal timestamp will be wrong. *		 * @param {Date} d		 * @return {Date} */		applyUserOffset: function (d) { var parts, offset = mw.user.options.get('timecorrection');

// This preference has no default value, it is null for users that don't // override the site's default timeoffset. if (offset) { parts = offset.split('|'); if (parts[0] === 'System') { // Ignore offset value, as system may have started or stopped // DST since the preferences were saved. offset = wikiTimeOffset; } else { offset = Number(parts[1]); }			} else { offset = wikiTimeOffset; }			// There is no way to set a timezone in javascript, so instead we pretend the // UTC timestamp is different and use getUTC* methods everywhere. d.setTime(d.getTime + (offset * 60 * 1000)); return d;		},

// Get clocktime string adjusted to timezone of wiki // from MediaWiki timestamp string getClocktimeFromApi: function (s) { var d = timeUtil.applyUserOffset(timeUtil.newDateFromISO(s)); // Return clocktime with leading zeros return pad(d.getUTCHours) + ':' + pad(d.getUTCMinutes); }	};

/**	 * Main functions * -	 */

/**	 * @param {Date} date * @return {string} HTML */	function buildRcDayHead(date) { var current = date.getDate; if (current === rcDayHeadPrev) { return ''; }		rcDayHeadPrev = current; return ' ' + date.getDate + ' ' + monthNames[date.getMonth] + ' '; }

/**	 * @param {Object} rc Recent change object from API * @return {string} HTML */	function buildRcItem(rc) { var diffsize, isUnpatrolled, isAnon, typeSymbol, itemClass, diffLink, el, item;

// Get size difference (can be negative, zero or positive) diffsize = rc.newlen - rc.oldlen;

// Convert undefined/empty-string values from API into booleans isUnpatrolled = rc.unpatrolled !== undefined; isAnon = rc.anon !== undefined;

// typeSymbol, diffLink & itemClass typeSymbol = ' '; itemClass = [];

if (rc.type === 'new') { typeSymbol += ' N '; }

if ((rc.type === 'edit' || rc.type === 'new') && userHasPatrolRight && isUnpatrolled) { typeSymbol += ' ! ';		}

if (rc.oldlen > 0 && rc.newlen === 0) { itemClass.push('mw-rtrc-item-alert'); }

/* Example:

(diff) ! 00:00 Page Abc talk / contribs Abc (0) 		*/

// build & return item item = buildRcDayHead(timeUtil.newDateFromISO(rc.timestamp)); item += '';

if (rc.type === 'edit') { diffLink = '' + mw.message('diff').escaped + ''; } else if (rc.type === 'new') { diffLink = 'new'; } else { diffLink = mw.message('diff').escaped; }

item += ' ' + '(' + diffLink + ') ' + typeSymbol + ' ' + timeUtil.getClocktimeFromApi(rc.timestamp) + ' ' + rc.title + '</a>' + ' ' +			' &middot; ' + '' + mw.message('talkpagelinktext').escaped + '</a>' + ' &middot; ' + '' + mw.message('contribslink').escaped + '</a>' + ' &middot; ' + '' + rc.user + '</a>' + ' ' +			' ' + rc.parsedcomment + '  ';

if (diffsize > 0) { el = diffsize > 399 ? 'strong' : 'span'; item += ' <' + el + ' class="mw-plusminus mw-plusminus-pos">(+' + diffsize.toLocaleString + ')</' + el + '> '; } else if (diffsize === 0) { item += ' (0)  '; } else { el = diffsize < -399 ? 'strong' : 'span'; item += ' <' + el + ' class="mw-plusminus mw-plusminus-neg">(' + diffsize.toLocaleString + ')</' + el + '> '; }

item += ' '; return item; }

/**	 * @param {Object} newOpt * @param {string} [mode=normal] One of 'quiet' or 'normal' * @return {boolean} True if no changes were made, false otherwise */	function normaliseSettings(newOpt, mode) { var mod = false;

// MassPatrol requires a filter to be active if (newOpt.app.massPatrol && !newOpt.rc.user) { newOpt.app.massPatrol = false; mod = true; if (mode !== 'quiet') { alert(msg('masspatrol-requires-userfilter')); }		}

// MassPatrol implies AutoDiff if (newOpt.app.massPatrol && !newOpt.app.autoDiff) { newOpt.app.autoDiff = true; mod = true; }		// MassPatrol implies fetching only unpatrolled changes if (newOpt.app.massPatrol && !newOpt.rc.unpatrolled) { newOpt.rc.unpatrolled = true; mod = true; }

return !mod; }

function fillSettingsForm(newOpt) { var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');

if (newOpt.rc) { $.each(newOpt.rc, function (key, value) {				var $setting = $settings.filter(function { return this.name === key; }),					setting = $setting[0];

if (!setting) { return; }

switch (key) { case 'limit': setting.value = value; break; case 'namespace': if (value === undefined) { // Value "" (all) is represented by undefined. $setting.find('option').eq(0).prop('selected', true); } else { $setting.val(value); }					break; case 'user': case 'start': case 'end': case 'tag': setting.value = value || ''; break; case 'hideliu': case 'hidebots': case 'unpatrolled': case 'typeEdit': case 'typeNew': setting.checked = value; break; case 'dir': if (setting.value === value) { setting.checked = true; }					break; }			});		}

if (newOpt.app) { $.each(newOpt.app, function (key, value) {				var $setting = $settings.filter(function { return this.name === key; }),					setting = $setting[0];

if (!setting) { setting = document.getElementById('rc-options-' + key); $setting = $(setting); }

if (!setting) { return; }

switch (key) { case 'cvnDB': case 'ores': case 'massPatrol': case 'autoDiff': setting.checked = value; break; case 'refresh': setting.value = value; break; }			});		}

}

function readSettingsForm { // jQuery#serializeArray is nice, but doesn't include "value: false" for unchecked // checkboxes that are not disabled. Using raw .elements instead and filtering // out. var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');

opt = $.extend(true, {}, defOpt);

$settings.each(function (i, el) {			var name = el.name;

switch (name) { // RC			case 'limit': opt.rc[name] = Number(el.value); break; case 'namespace': // Can be "0". // Value "" (all) is represented by undefined. // TODO: Turn this into a multi-select, the API supports it. opt.rc[name] = el.value.length ? Number(el.value) : undefined; break; case 'user': case 'start': case 'end': case 'tag': opt.rc[name] = el.value || undefined; break; case 'hideliu': case 'hidebots': case 'unpatrolled': case 'typeEdit': case 'typeNew': opt.rc[name] = el.checked; break; case 'dir': // There's more than 1 radio button with this name in this loop, // use the value of the first (and only) checked one. if (el.checked) { opt.rc[name] = el.value; }				break; // APP case 'cvnDB': case 'ores': case 'massPatrol': case 'autoDiff': opt.app[name] = el.checked; break; case 'refresh': opt.app[name] = Number(el.value); break; }		});

if (!normaliseSettings(opt)) { // TODO: Optimise this, no need to repopulate the entire settings form // if only 1 thing changed. fillSettingsForm(opt); }	}

function getPermalink { var uri = new mw.Uri(mw.util.getUrl(conf.wgPageName)), reducedOpt = {};

$.each(opt.rc, function (key, value) {			if (defOpt.rc[key] !== value) {				if (!reducedOpt.rc) {					reducedOpt.rc = {};				}				reducedOpt.rc[key] = value;			}		});

$.each(opt.app, function (key, value) {			// Don't permalink MassPatrol (issue Krinkle/mw-rtrc-gadget#59)			if (key !== 'massPatrol' && defOpt.app[key] !== value) {				if (!reducedOpt.app) {					reducedOpt.app = {};				}				reducedOpt.app[key] = value;			}		});

reducedOpt = JSON.stringify(reducedOpt);

uri.extend({			opt: reducedOpt === '{}' ? '' : reducedOpt		});

return uri.toString; }

function updateFeedNow { $('#rc-options-pause').prop('checked', false); if (updateReq) { // Try to abort the current request updateReq.abort; }		clearTimeout(updateFeedTimeout); return updateFeed; }

/**	 * @param {jQuery} $element */	function scrollIntoView($element) { $element[0].scrollIntoView({ block: 'start', behavior: 'smooth' }); }

/**	 * @param {jQuery} $element */	function scrollIntoViewIfNeeded($element) { if ($element[0].scrollIntoViewIfNeeded) { $element[0].scrollIntoViewIfNeeded({ block: 'start', behavior: 'smooth' }); } else { $element[0].scrollIntoView({ block: 'start', behavior: 'smooth' }); }	}

// Read permalink into the program and reflect into settings form. function readPermalink { var group, oldKey, newKey, newOpt, url = new mw.Uri;

if (url.query.opt) { try { newOpt = JSON.parse(url.query.opt); } catch (e) { // TODO: Report error to user }		}		if (newOpt) { // Rename values for old aliases for (group in newOpt) { for (oldKey in newOpt[group]) { newKey = aliasOpt[oldKey]; if (newKey && !newOpt[group].hasOwnProperty(newKey)) { newOpt[group][newKey] = newOpt[group][oldKey]; delete newOpt[group][oldKey]; }				}			}

if (newOpt.app) { // Don't permalink MassPatrol (issue Krinkle/mw-rtrc-gadget#59) delete newOpt.app.massPatrol; }		}

newOpt = $.extend(true, {}, defOpt, newOpt);

normaliseSettings(newOpt, 'quiet'); fillSettingsForm(newOpt);

opt = newOpt; }

function getApiRcParams(rc) { var params, rcprop = [ 'flags', 'timestamp', 'user', 'title', 'parsedcomment', 'sizes', 'ids' ],			rcshow = [], rctype = [];

if (userHasPatrolRight) { rcprop.push('patrolled'); }

if (rc.hideliu) { rcshow.push('anon'); }		if (rc.hidebots) { rcshow.push('!bot'); }		if (rc.unpatrolled) { rcshow.push('!patrolled'); }

if (rc.typeEdit) { rctype.push('edit'); }		if (rc.typeNew) { rctype.push('new'); }		if (!rctype.length) { // Custom default instead of MediaWiki's default (in case both checkboxes were unchecked) rctype = ['edit', 'new']; }

params = { rcdir: rc.dir, rclimit: rc.limit, rcshow: rcshow.join('|'), rcprop: rcprop.join('|'), rctype: rctype.join('|') };

if (rc.dir === 'older') { if (rc.end !== undefined) { params.rcstart = rc.end; }			if (rc.start !== undefined) { params.rcend = rc.start; }		} else if (rc.dir === 'newer') { if (rc.start !== undefined) { params.rcstart = rc.start; }			if (rc.end !== undefined) { params.rcend = rc.end; }		}

if (rc.namespace !== undefined) { params.rcnamespace = rc.namespace; }

if (rc.user !== undefined) { params.rcuser = rc.user; }

if (rc.tag !== undefined) { params.rctag = rc.tag; }

// params.titles: Title filter (rctitles) is no longer supported by MediaWiki, // see https://bugzilla.wikimedia.org/show_bug.cgi?id=12394#c5.

return params; }

// Called when the feed is regenerated before being inserted in the document function applyRtrcAnnotations($feedContent) { // Re-apply item classes $feedContent.filter('.mw-rtrc-item').each(function {			var $el = $(this),				rcid = Number($el.data('rcid'));

// Mark skipped and patrolled items as such if ($.inArray(rcid, skippedRCIDs) !== -1) { $el.addClass('mw-rtrc-item-skipped'); } else if (annotationsCache.patrolled.hasOwnProperty(rcid)) { $el.addClass('mw-rtrc-item-patrolled'); } else if (rcid === currentDiffRcid) { $el.addClass('mw-rtrc-item-current'); }		});	}

function applyOresAnnotations($feedContent) { var dAnnotations, revids, fetchRevids;

if (!oresModel) { return $.Deferred.resolve; }

// Find all revids names inside the feed revids = $.map($feedContent.filter('.mw-rtrc-item'), function (node) {			return $(node).attr('data-diff');		});

if (!revids.length) { return $.Deferred.resolve; }

fetchRevids = $.grep(revids, function (revid) {			return !annotationsCache.ores.hasOwnProperty(revid);		});

if (!fetchRevids.length) { // No (new) revisions dAnnotations = $.Deferred.resolve(annotationsCache.ores); } else { dAnnotations = $.ajax({				url: oresApiUrl,				data: {					models: oresModel,					revids: fetchRevids.join('|')				},				timeout: 10000,				dataType: $.support.cors ? 'json' : 'jsonp',				cache: true			}).then(function (resp) {				var len;				if (resp) {					len = Object.keys ? Object.keys(resp).length : fetchRevids.length;					annotationsCacheUp(len);					$.each(resp, function (revid, item) { if (!item || item.error || !item[oresModel] || item[oresModel].error) { return; }						annotationsCache.ores[revid] = item[oresModel].probability['true']; });				}				return annotationsCache.ores;			}); }

return dAnnotations.then(function (annotations) {			// Loop through all revision ids			$.each(revids, function (i, revid) { var tooltip, score = annotations[revid]; // Only highlight high probability scores if (!score || score <= 0.45) { return; }				tooltip = msg('ores-damaging-probability', (100 * score).toFixed(0) + '%');

// Add alert $feedContent .filter('.mw-rtrc-item[data-diff="' + Number(revid) + '"]') .addClass('mw-rtrc-item-alert mw-rtrc-item-alert-rev') .find('.mw-rtrc-meta') .prepend(						$(' ')							.addClass('mw-rtrc-revscore')							.attr('title', tooltip)					); });		});	}

function applyCvnAnnotations($feedContent) { var dAnnotations, users = [];

// Collect user names $feedContent.filter('.mw-rtrc-item').each(function {			var user = $(this).attr('user');			// Don't query the same user multiple times			if (user && $.inArray(user, users) === -1 && !annotationsCache.cvn.hasOwnProperty(user)) {				users.push(user);			}		});

if (!users.length) { // No (new) users dAnnotations = $.Deferred.resolve(annotationsCache.cvn); } else { dAnnotations = $.ajax({				url: cvnApiUrl,				data: { users: users.join('|') },				timeout: 2000,				dataType: $.support.cors ? 'json' : 'jsonp',				cache: true			}) .then(function (resp) {				if (resp.users) {					annotationsCacheUp(resp.users.length);					$.each(resp.users, function (name, user) { annotationsCache.cvn[name] = user; });				}				return annotationsCache.cvn;			}); }

return dAnnotations.then(function (annotations) {			// Loop through all cvn user annotations			$.each(annotations, function (name, user) { var tooltip;

// Only if blacklisted, otherwise don't highlight if (user.type === 'blacklist') { tooltip = '';

if (user.comment) { tooltip += msg('cvn-reason') + ': ' + user.comment + '. ';					} else { tooltip += msg('cvn-reason') + ': ' + msg('cvn-reason-empty'); }

if (user.adder) { tooltip += msg('cvn-adder') + ': ' + user.adder; } else { tooltip += msg('cvn-adder') + ': ' + msg('cvn-adder-empty'); }

// Add alert $feedContent .filter('.mw-rtrc-item') .filter(function {							return $(this).attr('user') === name;						}) .addClass('mw-rtrc-item-alert mw-rtrc-item-alert-user') .find('.mw-userlink') .attr('title', tooltip); }

});		});	}

/**	 * @param {Object} update * @param {jQuery} update.$feedContent * @param {string} update.rawHtml */	function pushFeedContent(update) { $body.removeClass('placeholder');

$feed.find('.mw-rtrc-feed-update').html(			message('lastupdate-rc', new Date.toLocaleString).escaped +			' | ' +			message('permalink').escaped +			'</a>'		);

if (update.rawHtml !== prevFeedHtml) { prevFeedHtml = update.rawHtml; applyRtrcAnnotations(update.$feedContent); $feed.find('.mw-rtrc-feed-content').empty.append(update.$feedContent); }	}

function updateFeed { if (updateReq) { updateReq.abort; }

// Indicate updating $('#krRTRC_loader').show;

// Download recent changes updateReq = $.ajax({			url: apiUrl,			dataType: 'json',			data: $.extend(getApiRcParams(opt.rc), { format: 'json', action: 'query', list: 'recentchanges' })		});		// This waterfall flows in one of two ways: // - Everything casts to success and results in a UI update (maybe an error message), //  loading indicator hidden, and the next update scheduled. // - Request is aborted and nothing happens (instead, the final handling will		//  be done by the new request). return updateReq.always(function {			updateReq = null;		}) .then(null, function (jqXhr, textStatus) {			var feedContentHTML = ' Downloading recent changes failed ';			if (textStatus === 'abort') {				return $.Deferred.reject;			}			pushFeedContent({ $feedContent: $(feedContentHTML), rawHtml: feedContentHTML });			// Error is handled. Move on normally.			return $.Deferred.resolve;		}).then(function (data) {			var recentchanges, $feedContent, client,				feedContentHTML = '';

if (data.error) { // Account doesn't have patrol flag if (data.error.code === 'rcpermissiondenied') { feedContentHTML += ' Downloading recent changes failed Please untick the "Unpatrolled only"-checkbox or request the Patroller-right.</a>';

// Other error } else { client = $.client.profile; feedContentHTML += ' Downloading recent changes failed ' + ' Please check the settings above and try again. If you believe this is a bug, please ' + 'let me know</a> .'; }			} else { recentchanges = data.query.recentchanges;

if (recentchanges.length) { $.each(recentchanges, function (i, rc) {						feedContentHTML += buildRcItem(rc);					}); } else { // Everything is OK - no results feedContentHTML += ' ' + message('nomatches').escaped + ' '; }

// Reset day rcDayHeadPrev = undefined; }

$feedContent = $($.parseHTML(feedContentHTML)); return $.when(				opt.app.cvnDB && applyCvnAnnotations($feedContent),				oresModel && opt.app.ores && applyOresAnnotations($feedContent)			).then(null, function {				// Ignore errors from annotation handlers				return $.Deferred.resolve;			}).then(function  {				pushFeedContent({ $feedContent: $feedContent, rawHtml: feedContentHTML });			});		}).then(function { $RCOptionsSubmit.prop('disabled', false).css('opacity', '1.0');

// Schedule next update updateFeedTimeout = setTimeout(updateFeed, opt.app.refresh * 1000); $('#krRTRC_loader').hide; });	}

function nextDiff { var $lis = $feed.find('.mw-rtrc-item:not(.mw-rtrc-item-current, .mw-rtrc-item-patrolled, .mw-rtrc-item-skipped)'); $lis.eq(0).find('a.rcitemlink').click; }

function wakeupMassPatrol(settingVal) { if (settingVal === true) { if (!currentDiff) { nextDiff; } else { $('.patrollink a').click; }		}	}

// Build the main interface function buildInterface { var namespaceOptionsHtml, tagOptionsHtml, key, fmNs = mw.config.get('wgFormattedNamespaces');

namespaceOptionsHtml = ' ' + mw.message('namespacesall').escaped + ' '; namespaceOptionsHtml += ' ' + mw.message('blanknamespace').escaped + ' ';

for (key in fmNs) { if (key > 0) { namespaceOptionsHtml += '<option value="' + key + '">' + fmNs[key] + ' '; }		}

tagOptionsHtml = ' ' + message('select-placeholder-none').escaped + ' '; for (key = 0; key < rcTags.length; key++) { tagOptionsHtml += '<option value="' + mw.html.escape(rcTags[key]) + '">' + mw.html.escape(rcTags[key]) + ' '; }

$wrapper = $($.parseHTML( ' ' +			' ' +				message('title').escaped + ' (' + appVersion + ') ' + ' ' +					(!mw.user.isAnon ? ( '' + message('mypatrollog').escaped + '</a>' ) : '') +					'' + message('help').escaped + '</a>' + ' ' +			' ' +			'<form id="krRTRC_RCOptions" class="mw-rtrc-settings mw-rtrc-nohelp make-switch"> ' + ' ' +					' ' +						' ' + message('filter').escaped + ' ' + ' ' +							' ' +								'<input type="checkbox" name="hideliu" />' + ' ' + message('filter-hideliu').escaped + ' ' +							' ' +							' ' +								'<input type="checkbox" name="hidebots" />' + ' ' + message('filter-hidebots').escaped + ' ' +						' ' +						' ' +							' ' +								'<input type="checkbox" name="unpatrolled" />' + ' ' + message('filter-unpatrolled').escaped + ' ' +							' ' +							' ' +								message('userfilter').escaped + ' : ' +								'<input type="search" size="16" name="user" />' + ' ' +						' ' +					' ' +					' ' +						' ' + message('type').escaped + ' ' + ' ' +							' ' +								'<input type="checkbox" name="typeEdit" checked />' + ' ' + message('typeEdit').escaped + ' ' +							' ' +							' ' +								'<input type="checkbox" name="typeNew" checked />' + ' ' + message('typeNew').escaped + ' ' +						' ' +					' ' +					' ' +						' ' +							mw.message('namespaces').escaped + ' ' +							'<select class="mw-rtrc-setting-select" name="namespace">' + namespaceOptionsHtml + ' ' +						' ' +					' ' +					' ' +						' ' +							message('timeframe').escaped + ' ' +						' ' +						' ' +							' ' +								message('time-from').escaped + ': ' + '<input type="text" size="16" placeholder="YYYYMMDDHHIISS" name="start" />' + ' ' +							' ' +							' ' +								message('time-untill').escaped + ': ' + '<input type="text" size="16" placeholder="YYYYMMDDHHIISS" name="end" />' + ' ' +						' ' +					' ' +					' ' +						' ' +							message('order').escaped + ' ' +							' ' +						' ' +						' ' +							' ' +								'<input type="radio" name="dir" value="newer" />' + ' ' + message('asc').escaped + ' ' +							' ' +							' ' +								'<input type="radio" name="dir" value="older" checked />' + ' ' + message('desc').escaped + ' ' +						' ' +					' ' +					' ' +						'<label for="mw-rtrc-settings-refresh" class="head">' + message('reload-interval').escaped + ' ' +							'<span section="Reload_Interval" class="helpicon"> ' + ' ' +						'<input type="number" value="3" min="0" max="99" size="2" id="mw-rtrc-settings-refresh" name="refresh" />' + ' ' +					' ' +						'<input class="button" type="button" id="RCOptions_submit" value="' + message('apply').escaped + '" />' + ' ' +				' ' +				' ' +					' ' +						'<label for="mw-rtrc-settings-limit" class="head">' + message('limit').escaped + ' ' + ' <select id="mw-rtrc-settings-limit" name="limit">' + ' 10 ' +							' 25 ' +							' 50 ' +							' 75 ' +							' 100 ' +							' 250 ' +							' 500 ' +						' ' +					' ' +					' ' +						' ' +							message('tag').escaped + ' <select class="mw-rtrc-setting-select" name="tag">' + tagOptionsHtml + ' ' +						' ' +					' ' +					' ' +						' ' +							'CVN Scores' + '<span section="CVN_Scores" class="helpicon"> ' + '<input type="checkbox" class="switch" name="cvnDB" />' + ' ' +					' ' +					(oresModel ? ( ' ' +							' ' +								'ORES Scores' + '<span section="ORES_Scores" class="helpicon"> ' + '<input type="checkbox" class="switch" name="ores" />' + ' ' +						' '					) : '') +					' ' +						' ' +							message('masspatrol').escaped + ' ' +							'<input type="checkbox" class="switch" name="massPatrol" />' + ' ' +					' ' +					' ' +						' ' +							message('autodiff').escaped + ' ' +							'<input type="checkbox" class="switch" name="autoDiff" />' + ' ' +					' ' +					' ' +						' ' +							message('pause').escaped + '<input class="switch" type="checkbox" id="rc-options-pause" />' + ' ' +					' ' +				' ' +			' ' +			'' + '<div class="mw-rtrc-diff mw-rtrc-diff-closed" id="krRTRC_DiffFrame"> ' + ' ' +				' ' +					' ' +					' ' +				' ' +				'<img src="' + ajaxLoaderUrl + '" id="krRTRC_loader" style="display: none;" />' + ' ' +					message('legend').escaped + ': ' + ' ' + mw.message('markedaspatrolled').escaped + ', ' + ' ' + message('currentedit').escaped + ', ' + ' ' + message('skippededit').escaped + ' ' + ' ' +			' ' +			' ' +			' ' +				' ' +					'Real-Time Recent Changes by ' + 'Krinkle</a>' + ' | ' + message('documentation').escaped + '</a>' + ' | ' + message('changelog').escaped + '</a>' + ' | Feedback</a>' + ' | License</a>' + ' ' +			' ' +		' '		));

// Add helper element for switch checkboxes $wrapper.find('input.switch').after(' ');

// All links within the diffframe should open in a new window $wrapper.find('#krRTRC_DiffFrame').on('click', 'table.diff a', function {			var $el = $(this);			if ($el.is('[href^="http://"], [href^="https://"], [href^="//"]')) {				$el.attr('target', '_blank');			}		});

$('#content').empty.append($wrapper);

$body = $wrapper.find('.mw-rtrc-body'); $feed = $body.find('.mw-rtrc-feed'); }

function annotationsCacheUp(increment) { annotationsCacheSize += increment || 1; if (annotationsCacheSize > 1000) { annotationsCache.patrolled = {}; annotationsCache.ores = {}; annotationsCache.cvn = {}; }	}

// Bind event hanlders in the user interface function bindInterface { var api = new mw.Api; $RCOptionsSubmit = $('#RCOptions_submit');

// Apply button $RCOptionsSubmit.click(function {			$RCOptionsSubmit.prop('disabled', true).css('opacity', '0.5');

readSettingsForm;

updateFeedNow.then(function {				wakeupMassPatrol(opt.app.massPatrol);			}); return false; });

// Close Diff $wrapper.on('click', '#diffClose', function {			$('#krRTRC_DiffFrame').addClass('mw-rtrc-diff-closed');			currentDiff = currentDiffRcid = false;		});

// Load diffview on (diff)-link click $feed.on('click', 'a.diff', function (e) {			var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),				title = $item.find('.page').text,				href = $(this).attr('href'),				$frame = $('#krRTRC_DiffFrame');

$feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');

currentDiff = Number($item.data('diff')); currentDiffRcid = Number($item.data('rcid'));

$frame .addClass('mw-rtrc-diff-loading') // Reset class potentially added by a.newPage or diffClose .removeClass('mw-rtrc-diff-newpage mw-rtrc-diff-closed');

$.ajax({				url: mw.util.wikiScript,				dataType: 'html',				data: {					action: 'render',					diff: currentDiff,					diffonly: '1',					uselang: conf.wgUserLanguage				}			}).fail(function (jqXhr) {				$frame					.append(jqXhr.responseText || 'Loading diff failed.')					.removeClass('mw-rtrc-diff-loading');			}).done(function (data) {				var skipButtonHtml, $diff;				if ($.inArray(currentDiffRcid, skippedRCIDs) !== -1) {					skipButtonHtml = ' ' + message('unskip').escaped + '</a> ';				} else {					skipButtonHtml = ' ' + message('skip').escaped + '</a> ';				}

$frame .html(data) .prepend(						' ' + mw.html.escape(title) + ' ' +						' ' +							' ' + message('close').escaped + '</a> ' +							' <a href="' + href + '" target="_blank" id="diffNewWindow">Open in Wiki</a> ' +							(userHasPatrolRight ? ' <a onclick="(function{ if($(\'.patrollink a\').length){ $(\'.patrollink a\').click; } else { $(\'#diffSkip\').click; } });">[mark]</a> ' : ''							) +							' <a id="diffNext">' + mw.message('next').escaped + ' &raquo;</a> ' +							skipButtonHtml +						' '					) .removeClass('mw-rtrc-diff-loading');

if (opt.app.massPatrol) { $frame.find('.patrollink a').click; } else { $diff = $frame.find('table.diff'); if ($diff.length) { mw.hook('wikipage.diff').fire($diff.eq(0)); }					// Only scroll up if the user scrolled down // Leave scroll offset unchanged otherwise scrollIntoViewIfNeeded($frame); }			});

e.preventDefault; });

$feed.on('click', 'a.newPage', function (e) {			var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),				title = $item.find('.page').text,				href = $item.find('.page').attr('href'),				$frame = $('#krRTRC_DiffFrame');

$feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');

currentDiffRcid = Number($item.data('rcid'));

$frame .addClass('mw-rtrc-diff-loading mw-rtrc-diff-newpage') .removeClass('mw-rtrc-diff-closed');

$.ajax({				url: href,				dataType: 'html',				data: {					action: 'render',					uselang: conf.wgUserLanguage				}			}).fail(function (jqXhr) {				$frame					.append(jqXhr.responseText || 'Loading diff failed.')					.removeClass('mw-rtrc-diff-loading');			}).done(function (data) {				var skipButtonHtml;				if ($.inArray(currentDiffRcid, skippedRCIDs) !== -1) {					skipButtonHtml = ' <a id="diffUnskip">' + message('unskip').escaped + '</a> ';				} else {					skipButtonHtml = ' <a id="diffSkip">' + message('skip').escaped + '</a> ';				}

$frame .html(data) .prepend(						' ' + title + ' ' +						' ' +							' <a id="diffClose">X</a> ' +							' <a href="' + href + '" target="_blank" id="diffNewWindow">Open in Wiki</a> ' +							' <a onclick="$(\'.patrollink a\').click">[mark]</a> ' +							' <a id="diffNext">' + mw.message('next').escaped + ' &raquo;</a> ' +							skipButtonHtml +						' '					) .removeClass('mw-rtrc-diff-loading');

if (opt.app.massPatrol) { $frame.find('.patrollink a').click; }			});

e.preventDefault; });

// Mark as patrolled $wrapper.on('click', '.patrollink', function {			var $el = $(this);			$el.find('a').text(mw.msg('markaspatrolleddiff') + '...');			api.postWithToken('patrol', { action: 'patrol', rcid: currentDiffRcid }).done(function (data) { if (!data || data.error) { $el.empty.append(						$(' ').text(mw.msg('markedaspatrollederror'))					); mw.log('Patrol error:', data); return; }				$el.empty.append(					$(' ').text(mw.msg('markedaspatrolled'))				); $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-patrolled');

// Feed refreshes may overlap with patrol actions, which can cause patrolled edits // to show up in an "Unpatrolled only" feed. This is make nextDiff skip those. annotationsCacheUp; annotationsCache.patrolled[currentDiffRcid] = true;

if (opt.app.autoDiff) { nextDiff; }			}).fail(function { $el.empty.append(					$(' ').text(mw.msg('markedaspatrollederror'))				); });

return false; });

// Trigger NextDiff $wrapper.on('click', '#diffNext', function {			nextDiff;		});

// SkipDiff $wrapper.on('click', '#diffSkip', function {			$feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-skipped');			// Add to array, to re-add class after refresh			skippedRCIDs.push(currentDiffRcid);			nextDiff;		});

// UnskipDiff $wrapper.on('click', '#diffUnskip', function {			$feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').removeClass('mw-rtrc-item-skipped');			// Remove from array, to no longer re-add class after refresh			skippedRCIDs.splice(skippedRCIDs.indexOf(currentDiffRcid), 1);		});

// Show helpicons $('#mw-rtrc-toggleHelp').click(function (e) {			e.preventDefault;			$('#krRTRC_RCOptions').toggleClass('mw-rtrc-nohelp mw-rtrc-help');		});

// Link helpicons $('.mw-rtrc-settings .helpicon') .attr('title', msg('helpicon-tooltip')) .click(function (e) {				e.preventDefault;				window.open(docUrl + '#' + $(this).attr('section'), '_blank');			});

// Mark as patrolled when rollbacking // Note: As of MediaWiki r(unknown) rollbacking does already automatically patrol all reverted revisions. // But by doing it anyway it saves a click for the AutoDiff-users $wrapper.on('click', '.mw-rollback-link a', function {			$('.patrollink a').click;		});

// Button: Pause $('#rc-options-pause').click(function {			if (!this.checked) {				// Unpause				updateFeedNow;				return;			}			clearTimeout(updateFeedTimeout);		}); }

function showUnsupported { $('#content').empty.append(			$(' ').addClass('errorbox').text( 'This program requires functionality not supported in this browser.' )		);	}

/**	 * @param {string} [errMsg] */	function showFail(errMsg) { $('#content').empty.append(			$(' ').addClass('errorbox').text(errMsg || 'An unexpected error occurred.')		); }

/**	 * Init functions * -	 */

/**	 * Fetches all external data we need. *	 * This runs in parallel with loading of modules and i18n. *	 * @return {jQuery.Promise} */	function initData { var promises = [];

// Get userrights promises.push(			mw.loader.using('mediawiki.user').then(function { return mw.user.getRights.then(function (rights) {					if ($.inArray('patrol', rights) !== -1) {						userHasPatrolRight = true;					}				}); })		);

// Get MediaWiki interface messages promises.push(			mw.loader.using('mediawiki.api.messages').then(function { return new mw.Api.loadMessages([					'blanknamespace',					'contributions',					'contribslink',					'diff',					'markaspatrolleddiff',					'markedaspatrolled',					'markedaspatrollederror',					'namespaces',					'namespacesall',					'next',					'talkpagelinktext'				]); })		);

promises.push($.ajax({ url: apiUrl, dataType: 'json', data: { format: 'json', action: 'query', list: 'tags', tgprop: 'displayname' }		}).then(function (data) { var tags = data.query && data.query.tags; if (tags) { rcTags = $.map(tags, function (tag) {					return tag.name;				}); }		}));

promises.push($.ajax({ url: apiUrl, dataType: 'json', data: { format: 'json', action: 'query', meta: 'siteinfo' }		}).then(function (data) { wikiTimeOffset = (data.query && data.query.general.timeoffset) || 0; }));

return $.when.apply(null, promises); }

/**	 * @return {jQuery.Promise} */	function init { var dModules, dI18N, featureTest, $navToggle, dOres;

// Transform title and navigation tabs document.title = 'RTRC: ' + conf.wgDBname; $(function {			$('#p-namespaces ul')				.find('li.selected')					.removeClass('new')					.find('a')						.text('RTRC');		});

featureTest = !!(Date.parse);

if (!featureTest) { $(showUnsupported); return; }

// These selectors from vector-hd conflict with mw-rtrc-available $('.vector-animateLayout').removeClass('vector-animateLayout');

$('html').addClass('mw-rtrc-available');

if (navSupported) { $('html').addClass('mw-rtrc-sidebar-toggleable'); $(function {				$navToggle = $(' ').addClass('mw-rtrc-navtoggle');				$('body').append($(' ').addClass('mw-rtrc-sidebar-cover'));				$('#mw-panel')					.append($navToggle)					.hover(function  { $('html').addClass('mw-rtrc-sidebar-on'); }, function { $('html').removeClass('mw-rtrc-sidebar-on'); });			});		}

dModules = mw.loader.using([			'json',			'jquery.client',			'mediawiki.action.history.diff',			// mw-plusminus styles etc.			'mediawiki.special.changeslist',			'mediawiki.jqueryMsg',			'mediawiki.Uri',			'mediawiki.user',			'mediawiki.util',			'mediawiki.api',			'mediawiki.api.messages'		]);

if (!mw.libs.getIntuition) { mw.libs.getIntuition = $.ajax({ url: intuitionLoadUrl, dataType: 'script', cache: true, timeout: 7000 /*ms*/ }); }

dOres = $.ajax({			url: oresApiUrl,			dataType: $.support.cors ? 'json' : 'jsonp',			cache: true,			timeout: 2000		}).then(function (data) {			if (data && data.models) {				if (data.models.damaging) {					oresModel = 'damaging';				} else if (data.models.reverted) {					oresModel = 'reverted';				}			}		}, function {			// If ORES doesn't have models for this wiki, do continue loading without			return $.Deferred.resolve;		});

dI18N = mw.libs.getIntuition .then(function {				return mw.libs.intuition.load('rtrc');			}) .then(function {				message = $.proxy(mw.libs.intuition.message, null, 'rtrc');				msg = $.proxy(mw.libs.intuition.msg, null, 'rtrc');			}, function  {				// Ignore failure. RTRC should load even if Labs is down.				// Fallback to displaying message keys.				mw.messages.set('intuition-i18n-gone', '$1');				message = function (key) {					return mw.message('intuition-i18n-gone', key);				};				msg = function (key) {					return key;				};				return $.Deferred.resolve;			});

$.when(initData, dModules, dI18N, dOres, $.ready).fail(showFail).done(function {			if ($navToggle) {				$navToggle.attr('title', msg('navtoggle-tooltip'));			}

// Map over months monthNames = msg('months').split(',');

buildInterface; readPermalink; updateFeedNow;

scrollIntoView($wrapper); rAF(function {				$('html').addClass('mw-rtrc-ready');			});

bindInterface; });	}

/**	 * Execution * -	 */

// On every page $(function {		if (!$('#t-rtrc').length) {			mw.loader.using('mediawiki.util', function  { mw.util.addPortletLink(					'p-tb',					mw.util.getUrl('Special:BlankPage/RTRC'),					'RTRC',					't-rtrc',					'Monitor and patrol recent changes in real-time',					null,					'#t-specialpages'				); });		}	});

// Initialise if in the right context if (		(conf.wgTitle === 'Krinkle/RTRC' && conf.wgAction === 'view') ||		(conf.wgCanonicalSpecialPageName === 'Blankpage' && conf.wgTitle.split('/', 2)[1] === 'RTRC')	) { init; }

}(jQuery, mediaWiki));