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. |
/**
* 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));