/**
* 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>',
' (',
'<a href="', encodeURI(
g_wArticlePath.replace('$1', 'User_talk:') + retVal), '">',
'Talk',
'</a>',
' | ',
'<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>',
' (',
'<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', ''), ' ',
'<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>' +
' | ' :
''),
'<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));