User:Unready/app.wlist.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | Documentation for this user script can be added at User:Unready/app.wlist. This user script seems to have an accompanying .css page at User:Unready/app.wlist.css. |
// __NOINDEX__
/**
* 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 WikiMedia; Use prefix constant
*/
((window.user = window.user || {}).app = window.user.app || {}).wlist =
(function (mw, $)
{
'use strict';
var
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
PREFIX = 'app-wlist';
var
g_self =
{
interval: INTERVAL,
maxAge: MAXAGE,
message: 'Initializing',
run: run,
stop: stop,
version: '1.1.1, 5 Mar 2016'
},
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_wServer = mw.config.get('wgServer'),
g_wScriptPath = mw.config.get('wgScriptPath'),
g_wArticlePath = mw.config.get('wgArticlePath'),
g_list, // revisions data 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_semReq = newSemaphore(), // for outstanding requests
g_semThread = newSemaphore(), // for running threads
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
value = 0,
self;
self =
{
dec: function ()
{
return --value;
},
inc: function ()
{
return ++value;
},
val: function ()
{
return value;
}
};
return self;
}
// get interval (sec) from module properties
function getInterval()
{
if ((typeof g_self.interval !== 'number') ||
(g_self.interval < 60) || // 1 minute
(g_self.interval > 7200 )) // 2 hours
{
g_self.interval = INTERVAL; // reset to default if insane
}
else
{
g_self.interval = Math.floor(g_self.interval);
}
return g_self.interval;
}
// get maxAge (hour) from module properties
function getMaxAge()
{
if ((typeof g_self.maxAge !== 'number') ||
(g_self.maxAge < 2) ||
(g_self.maxAge > 8784)) // 366 days
{
g_self.maxAge = MAXAGE; // reset to default if insane
}
else
{
g_self.maxAge = Math.floor(g_self.maxAge);
}
return g_self.maxAge;
}
// call the MediaWiki API using POST
// req = xmlHttpRequest object
// action = api parameters
// next = callback on success
// semaphore = count of outstanding requests
function apiCall(req, action, next, semaphore)
{
var
urlAPI = g_wServer + g_wScriptPath + '/api.php';
req.open('POST', urlAPI, true);
req.setRequestHeader('Content-Type',
'application/x-www-form-urlencoded;');
req.onreadystatechange = function ()
{
if (req.readyState === 4)
{
semaphore.dec();
req.onreadystatechange = null;
if (req.status === 200)
{
next();
}
else
{
g_self.message = 'apiCall :: "' +
action + '" returned "' + req.statusText + '"';
}
}
};
semaphore.inc();
req.send(action);
}
// 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])$'
),
ipv6pos = new RegExp // ipv6 should pass pos and fail neg
(
'^(?:(?:[1-9a-f][\da-f]{0,3}|0?):){2,7}' +
'(?:[1-9a-f][\da-f]{0,3}|0?)$',
'i'
),
ipv6neg = new RegExp
(
'(?:^(?:(?:[1-9a-f][\da-f]{0,3}|0):){1,6}' +
'(?:[1-9a-f][\da-f]{0,3}|0)$|' +
'::.*::|' +
':::|' +
'^:[^:]|' +
'[^:]:$)',
'i'
);
var
retVal;
if (!ipv4.test(userRaw) &&
(!ipv6pos.test(userRaw) || ipv6neg.test(userRaw)))
{ // registered user
retVal = userRaw.replace(/ /g, '_');
retVal = String.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.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.concat
(
'<span class="mw-plusminus-pos">',
'(+', retVal.toString(), ')',
'</span>'
);
}
else if (retVal < 0)
{
retVal = String.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/></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.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.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 change property into list by matching titles
// merge bot property into list by matching users
for ( i = 0 ; i < g_list.length ; ++i )
{
if (g_changed.indexOf(g_list[i].title) !== -1)
{
g_list[i].changed = ''; // flag the change
}
if (g_bots.indexOf(g_list[i].user) !== -1)
{
g_list[i].bot = ''; // flag the bot
}
}
// 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);
}
// thread 3 - bot users
function queryListUsers()
{
var
req = new XMLHttpRequest();
// process list=users & usprop=groups return
// possibly multiple times
function onGroups()
{
var
o, a, i;
if (g_cancel)
{
return;
}
o = JSON.parse(req.responseText);
if (o.error !== undefined)
{
g_self.message = 'onGroups :: ' +
o.error.code + ': ' + o.error.info;
g_jStatMsg.text('onGroups :: XMLHttpRequest error');
g_jBox
.prop('disabled', true)
.prop('checked', false);
return;
}
if ((o.query === undefined) || (o.query.users === undefined))
{
g_self.message = 'onGroups :: ' +
req.responseText;
g_jStatMsg.text('onGroups :: Query ended abnormally.');
g_jBox
.prop('disabled', true)
.prop('checked', false);
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(); // keep going until no more users
}
// request list=users & usprop=groups from the api for the users
function reqGroups()
{
var
action =
[
'format=json',
'action=query',
'list=users',
'usprop=groups',
'ususers='
].join('&');
if (g_users.length > 0)
{
action += encodeURIComponent(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
apiCall(req, action, onGroups, g_semReq);
}
else
{
g_semThread.dec(); // release part of the merge lock
mergeThreads();
}
}
g_bots = []; // init thread 3 output shared area
g_semThread.inc(); // semaphore for thread 3
reqGroups();
}
// thread 2 - changed articles
function queryListWatchlist()
{
var
req = new XMLHttpRequest();
// process list=watchlistraw & wrshow=changed return
// possibly multiple times if continuation
function onChanged()
{
var
o, a, i;
if (g_cancel)
{
return;
}
o = JSON.parse(req.responseText);
if (o.error !== undefined)
{
g_self.message = 'onChanged :: ' +
o.error.code + ': ' + o.error.info;
g_jStatMsg.text('onChanged :: XMLHttpRequest error');
return;
}
if (o.watchlistraw === undefined)
{
g_self.message = 'onChanged :: ' +
req.responseText;
g_jStatMsg.text('onChanged :: Query ended abnormally.');
g_jBox
.prop('disabled', true)
.prop('checked', false);
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(o);
return;
}
g_semThread.dec(); // release part of the merge lock
mergeThreads();
}
// get info to flag unread revisions
// request list=watchlistraw & wrshow=changed
// optional parameter is for continuation
function reqChanged(c)
{
var
action =
[
'format=json',
'action=query',
'list=watchlistraw',
'wrlimit=' + MAXRES,
'wrshow=changed'
].join('&'),
i;
if (c !== undefined)
{
for (i in c)
{
if (c.hasOwnProperty(i))
{
action += '&' + i + '=' + encodeURIComponent(c[i]);
}
}
}
// returns only revisions which are unread
// watchlistraw: array -> title: string
apiCall(req, action, onChanged, g_semReq);
}
g_changed = []; // init thread 2 output shared area
g_semThread.inc(); // semaphore for thread 2
reqChanged();
}
// thread 1 - article revisions and parent revisions
function queryPropRevisions()
{
var
req = new XMLHttpRequest(),
parent = []; // rev IDs of parents
// process prop=revisions return for the parents
// possibly multiple times
function onParentRevs()
{
var
o, a,
i, j,
found;
if (g_cancel)
{
return;
}
o = JSON.parse(req.responseText);
if (o.error !== undefined)
{
g_self.message = 'onParentRevs :: ' +
o.error.code + ': ' + o.error.info;
g_jStatMsg.text('onParentRevs :: XMLHttpRequest error');
g_jBox
.prop('disabled', true)
.prop('checked', false);
return;
}
if (!$.isArray(o)) // empty result set is Object([])
{
if ((o.query === undefined) || (o.query.pages === undefined))
{
g_self.message = 'onParentRevs :: ' +
req.responseText;
g_jStatMsg.text('onParentRevs :: Query ended abnormally.');
g_jBox
.prop('disabled', true)
.prop('checked', false);
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(); // keep going until no more parents
}
// request prop=revisions from the api for the parents
function reqParentRevs()
{
var
action =
[
'format=json',
'action=query',
'prop=revisions',
'rvprop=size',
'revids='
].join('&');
if (parent.length > 0)
{
action += encodeURIComponent(parent.slice(0, MAXREQ).join('|'));
parent = parent.slice(MAXREQ);
apiCall(req, action, onParentRevs, g_semReq);
}
else
{
g_semThread.dec(); // release part of the merge lock
mergeThreads();
}
}
// process prop=revisions return
// possibly multiple times if continuation
function onCurrentRevs()
{
var
o, a, i;
if (g_cancel)
{
return;
}
o = JSON.parse(req.responseText);
if (o.error !== undefined)
{
g_self.message = 'onCurrentRevs :: ' +
o.error.code + ': ' + o.error.info;
g_jStatMsg.text('onCurrentRevs :: XMLHttpRequest error');
g_jBox
.prop('disabled', true)
.prop('checked', false);
return;
}
if (!$.isArray(o)) // empty result set is Object([])
{
if ((o.query === undefined) || (o.query.pages === undefined))
{
g_self.message = 'onCurrentRevs :: ' +
req.responseText;
g_jStatMsg.text('onCurrentRevs :: Query ended abnormally.');
g_jBox
.prop('disabled', true)
.prop('checked', false);
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].timestamp > g_isoFrom))
{
a[i].revisions[0].title = a[i].title;
g_list.push(a[i].revisions[0]);
}
}
// find the continuation data, if it exists
o = o.continue || ((o = o['query-continue']) && o.watchlistraw);
if (o !== undefined)
{ // get more list items
reqCurrentRevs(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)
{
parent.push(g_list[i].parentid);
}
if (g_users.indexOf(g_list[i].user) === -1)
{
g_users.push(g_list[i].user);
}
}
reqParentRevs(); // continue thread 1
queryListUsers(); // fork thread 3
}
// request prop=revisions from the api
// optional parameter is for continuation
function reqCurrentRevs(c)
{
var
rvprop = 'ids|flags|user|size|timestamp|parsedcomment',
action =
[
'format=json',
'action=query',
'prop=revisions',
'rvprop=' + encodeURIComponent(rvprop),
'generator=watchlistraw',
'gwrlimit=' + MAXRES
].join('&'),
i;
if (c !== undefined)
{
for (i in c)
{
if (c.hasOwnProperty(i))
{
action += '&' + i + '=' + encodeURIComponent(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}
apiCall(req, action, onCurrentRevs, g_semReq);
}
g_list = []; // init thread 1 output shared areas
g_users = [];
g_semThread.inc(); // semaphore for thread 1
reqCurrentRevs();
}
// 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
queryPropRevisions(); // thread 1
queryListWatchlist(); // 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
{
if (g_semThread.val() > 0)
{
while (g_semThread.dec() > 0); // reset all threads
}
g_self.message = '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;
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.concat
(
'<p/>',
'<p class="' + PREFIX + '-stat">',
'<input type="checkbox"/>',
' Refresh: ',
'<span/>',
'</p>',
'<div/>'
)),
jWrapper = $('#' + PREFIX);
// abort if not one element
if (jWrapper.length !== 1)
{
g_self.message = '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
g_self.message = '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 g_self;
}(mediaWiki, jQuery));