//
// This is the source code of Catwatch2, a script that creates a pop-up displaying the last change
// that occurred to the pages of a list of categories.
//
// For a complete description see http://commons.wikimedia.org/wiki/User_talk:Ianezz/Catwatch2.js
//
// The code was authored by User:Ianezz, and available also under CC-BY-SA 3.0 and GFDL.
/**
* A very simple cache class that expires entries according to their max age or their hit number.
*
* The constrctor takes an object that may specify the following properties:
* - maxagemillisec (default -1) is the maximum age of a cache entry, in milliseconds. Use a negative value to disable.
* - highwatermark (default -1) is the maximum number of entries in the cache. Use a negative value to disable.
* - lowwatermark (default 0) is the number of entries that should stay in the cache after shrinking.
* - shrinkpolicy (default "hits") either "hits" or "age", and is the policy used to choose entries to be removed
* when shrinking hte cache ("hits" removes first entries with low cache hits, while "age" removes first older entries).
*/
function SimpleCache(p) {
// An object storing the values (the property name is the key).
this.data = {};
// An object storing metadata about each value (the property name is the key).
this.metadata = {};
// The current number of entries in the cache.
this.entries = 0;
// Cache properties (with default values)
this.prop = {
maxagemillisec: -1,
highwatermark: -1,
lowwatermark: 0,
shrinkpolicy: "hits"
};
// Let's set the cache properties using the object given as the argument.
// Complain if we try to set an unknown property.
var i;
for(i in p) {
if(this.prop[i]) {
this.prop[i] = p[i];
} else {
throw("Unknown property " + i);
}
}
/**
* Given a key, return either the associated value if it is in the cache and it
* is not expired, or the undefined value in all other cases.
*/
this.get = function(key) {
if (this.metadata[key]) {
if (this.prop.maxagemillisec >= 0) {
var now = new Date();
if ((now.getTime() - this.metadata[key].ts) <= this.prop.maxagemillisec) {
this.metadata[key].hits ++;
return this.data[key];
} else {
this.unset(key);
return undefined;
}
} else {
return this.data[key];
}
}
return undefined;
}
/**
* Store a value in the cache, indexed by its key.
*/
this.set = function(key,value) {
var now = new Date();
if (! this.contains[key]) {
this.entries++;
}
this.data[key] = value;
this.metadata[key] = {key: key, hits: 0, ts: now.getTime() };
if (this.prop.highwatermark > 0 && this.entries > this.prop.highwatermark) {
this.shrink();
}
}
/**
* Remove an entry from the cache, by its key.
*/
this.unset = function(key) {
if (this.contains[key]) { this.entries--; }
this.data[key] = undefined;
this.metadata[key] = undefined;
}
/**
* Tests if the cache contains an entry regardless of its expiration time.
*
* Please note this means that contains() may return true for a given key, while get()
* called shortly after could return an undefined value for the same key. This would be
* true even if we checked for the expiration time (a cache entry could expire right
* between the call to contains() and the call to get()).
*/
this.contains = function(key) {
return !(this.metadata[key] == undefined);
}
/**
* Remove entries from the cache to bring its size back to the lower watermark.
*/
this.shrink = function() {
var now = new Date();
var ts = now.getTime;
// First, unset all expired entries (if there's a max age)
if (this.prop.maxagemillisec >= 0) {
var key;
for(key in this.metadata) {
if ((ts - this.metadata[key].ts) <= this.prop.maxagemillisec) {
this.unset(key);
}
}
}
// Then, if we are still above the high water mark...
if (this.prop.highwatermark > 0 && this.entries > this.prop.highwatermark) {
// ...try to shrink ourselves.
if (this.prop.lowwatermark == 0) {
// Do this the quick way (delete all data from this cache)
this.data = {};
this.metadata = {};
this.entries = 0;
} else {
// Otherwise, unset entries by age or by cache hits, according to the
// shrink policy, until we get below the lowwatermark
var i,result = [];
for(i in this.metadata) { result.push(this.metadata[i]); }
switch(prop.shrinkpolicy) {
case "age":
// Older entries first, then hits number from lower to higher
result.sort(function(a,b) {
var x = a.ts - b.ts;
if ( x == 0 ) return a.hits - b.hits;
return x;
});
break;
case "hits":
// Low hits numbers first, then age.
result.sort(function(a,b) {
var x = a.hits - b.hits;
if ( x == 0 ) return a.ts - b.ts;
return x;
});
break;
default:
throw("Unknown sort key");
}
// We have to uset this number of entries
// to return below the low watermark.
var k = this.entries - this.prop.lowwatermark;
for(i in result.slice(result,k)) {
// So, let's do it.
this.unset(result[i].key);
}
}
}
}
};
catwatch2 = {
// The version to be displayed.
version: "0.7",
// The initial list of categories to watch...
watchedCategories: [ "Province of Trento" ],
/**
* Set the initial list of categories
*/
setCategories: function(arr) {
this.watchedCategories = arr;
},
/**
* Prepare the URL for a query using the given base and parameters.
* (escaping the value of parameters).
*
* @param baseUrl is the base URL to the MediaWiki API.
* When unspecified or empty, return just the parameters.
* The value can be specified with or withouth the final "?"
* to mark the begin of parameters (it is added if it's missing).
*
* Example (for Wikimedia Commons):
* http://commons.wikimedia.org/w/api.php
*
*
* @param queryParams is an object where each key is a parameter name
* and each value is a parameter value.
*
* Example:
* {action: "query", list: "categorymembers", cmtitle: "Category:Foo", cmprop: "title|timestamp" }
*
* @return the complete URL, with parameter names and values properly escaped.
*/
prepareQueryURL: function (baseURL, queryParams) {
this.log.trace("prepareQueryURL: baseURL=",baseURL,"queryParams=",queryParams);
var param, value;
var p = new Array();
for(param in queryParams) {
if (queryParams[param] instanceof Array) {
value = queryParams[param].join("|");
} else {
value = queryParams[param];
}
p.push( encodeURIComponent(param) + "=" + encodeURIComponent(value) );
}
if (baseURL && baseURL != "") {
// If we were specified a non-empty base URL, prepend that.
if(baseURL.slice(-1) == "?") {
// Don't add the question mark between the baseURL and the parameters
// if the baseURL already specifies it.
return baseURL + p.join("&");
} else {
return baseURL + "?" + p.join("&");
}
} else {
return p.join("&");
}
},
/**
* Parse JSON data using the internal parser when available, otherwise via eval().
*
* @param data is the JSON data to be parsed
*
* @return the object corresponding to that data.
*/
parseJSON: function(data) {
var f;
try {
// Try to use the builtin JSON parser
f = JSON.parse
} catch(err) {
// Provide a poor's man JSON parser.
// TODO: perform the safety replacements suggested in the RFC...
f = function(d) {
return (eval("(" + d + ")"));
}
}
// Replace ourselves with the chosen parser
this.parseJSON = f;
return f(data);
},
/**
* Perform a remote query via the MediaWiki API, either via POST or GET, dealing with
* the query-continue logic, and return the result. The query is performed synchronously.
*
* @param queryParams is an hashmap of parameters, as specified by the MediaWiki API.
* Please note that parameter "format" is forced to "json", because this
* function deals with query continuations (both with and without generators).
*
* @param method tells if the query should be performed via GET (cached, may have
* problems with large parameters) or via POST
* (which is not cached, and allows large parameters)
*/
doJSONQuery: function(queryParams,method) {
// Do a quick check if someone specified a format that is not "json" for the query results.
if (queryParams.format && queryParams.format != "json") {
this.log.error("catwatch2.doJSONQuery() was called with a format that is not json","("+ queryParams.format +")");
throw("Sorry, only json results are supported because we deal with query continuations.");
}
var result = [];
// Copy the parameters object, since
// we could have to perform the query in several steps
// (each step after the first requires us to repeat the query
// specifying also additional parameters)
var queryParamsCopy = {};
var i; for(i in queryParams) { queryParamsCopy[i] = queryParams[i]; };
if (this.askPrivilege) {
// This works only on Gecko-based browsers (Firefox), and is
// used to escape the same-origin policy for debugging purposes
// (this function is meant to be called only when this source is read
// from the local filesystem).
try {
netscape.security.PrivilegeManager.enablePrivilege("UniversalBrowserRead");
} catch(err) {
}
}
do {
var thereIsMoreWorkToDo = false;
// In any case: force the JSON format for the results.
queryParamsCopy.format = "json";
// Now, let's try to get our hands on an XMLHttpRequest object.
var request = window.XMLHttpRequest ? new XMLHttpRequest() // Firefox, Safari, Opera, IE7
: window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") // IE6
: false;
if (request) {
this.log.trace("catwatch2.doJSONQuery()", "obtained request");
var queryURL;
// Prepare a synchronous request
if (method == "GET") {
this.log.debug("catwatch2.doJSONQuery()","Using a GET method");
// Compute the URL for the query.
queryURL = this.prepareQueryURL(this.getAPIURL(),queryParamsCopy);
this.log.debug("catwatch2.doJSONQuery()","Query URL is ",queryURL);
request.open("GET", queryURL, false);
try {
request.send();
} catch (err) {
this.log.error("catwatch2.doJSONQuery()","caught an exception while using a GET method: ",err);
request = null;
}
} else {
this.log.debug("catwatch2.doJSONQuery()","using a POST method");
queryURL = this.getAPIURL();
var params = this.prepareQueryURL("",queryParamsCopy);
request.open("POST", queryURL, false);
request.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
request.setRequestHeader("Content-length", params.length);
request.setRequestHeader("Connection", "close");
try {
request.send(params);
} catch (err) {
this.log.error("catwatch2.doJSONQuery()","caught an exception while using a POST method: ",err);
request = null;
}
}
}
if (request) {
this.log.trace("Got response: ",request.responseText);
var obj;
// The returned data is JSON data: parse it and add the object to the result
obj = this.parseJSON(request.responseText);
this.log.trace("catwatch2.doJSONQuery()", "JSON object is ",obj);
result.push(obj);
// Now... is there more work to do?
if (obj["query-continue"]) {
this.log.debug("catwatch2.doJSONQuery()", "found a query-continue property");
// ... yes, there is more work to do, because
// we have a query-continue member in the reply.
thereIsMoreWorkToDo = true;
// Now, the query-continue member usually contains
// just one object, but the API documentation says
// there could be two when generators are used
// (one for the generator query and one for the real query).
// We have to ensure that we use just one at a time,
// giving precedence to the one for the real query.
//
// To do that, we add all query-continue members to an
// array, in increasing order of precedence, then pop
// the last element (which has necessarily the highest
// precedence).
//
var qcList = [];
for(j in obj["query-continue"]) {
// Did we use a generator in querying, and is this
// the generator-related query-continue parameter?
if(queryParams.generator && queryParams.generator == j) {
this.log.debug("catwatch2.doJSONQuery()","found a generator-related query-continue parameter");
// Yes... it is. This has lower priority over
// the non-generator-related query-continue,
// so put it at the beginning of the qc array.
qcList.splice(0,0,obj["query-continue"][j]);
} else {
this.log.debug("catwatch2.doJSONQuery()","found a NON-generator-related query-continue parameter");
// A non-generator-related query-continue parameter.
// Put it at the end of the qc array.
qcList.push(obj["query-continue"][j]);
}
}
if (qcList.length > 1 ) {
this.log.info("catwatch2.doJSONQuery()","please note that we found more than one query-continue parameter");
}
// Now, pop() the query-continue object from the end...
var qc = qcList.pop();
// ... and adjust the parameters accordingly.
var queryParamsCopy = {};
var i; for (i in queryParams) { queryParamsCopy[i] = queryParams[i]; };
var j; for (j in qc) { queryParamsCopy[j] = qc[j]; };
}
}
} while(thereIsMoreWorkToDo);
return result;
},
/**
* Perform a remote query asynchronously via the MediaWiki API, either via POST or GET,
* dealing with the query-continue logic.
*
* @param parameters is an object where each member is a parameter for the query.
* Please note that the query format must be "json" (i.e. parameter "format"
* is forced to be "json", and an exception is thrown if a different value
* is specified)
*
* @param method is either "GET" or "POST", and specifies which HTTP method should be
* used for the query
*
* @param callbacks is an object specifying the callbacks to be called:
* - onsuccess(queryresultobject,cbkdata) is called at each step on success
* - onerror(request,cbkdata) is called on errors
* - oncomplete(cbkdata) is called after all steps completed successfully
* (queryresultobject is the parsed JSON object, cbkdata is the object
* passed down to the call to this function, request is the
* XmlHttpRequest object). Note also that "this" in callbacks is the
* object where doJSONQuery2() is defined (and not the XmlHttpRequest obj).
*
* @param cbkdata is an object that is passed down to the callbacks unmodified
* (can be used to accumulate results, or to carry context, or both)
*/
doJSONQuery2: function(parameters,method,callbacks,cbkdata) {
// Do a quick check if someone specified a format that is not "json" for the query results.
if (parameters.format && parameters.format != "json") {
this.log.error("catwatch2.doJSONQuery2() was called with a format that is not json","("+ parameters.format +")");
throw("Sorry, only json results are supported because we deal with query continuations.");
}
var result = [];
// Copy the parameters object, since
// we could have to perform the query in several steps
// (each step after the first requires us to repeat the query
// specifying also additional parameters)
var queryParamsCopy = {};
var i; for(i in parameters) { queryParamsCopy[i] = parameters[i]; };
if (this.askPrivilege) {
// This works only on Gecko-based browsers (Firefox), and is
// used to escape the same-origin policy for debugging purposes
// (this function is meant to be called only when this source is read
// from the local filesystem).
try {
netscape.security.PrivilegeManager.enablePrivilege("UniversalBrowserRead");
} catch(err) {
}
}
// In any case: force the JSON format for the results.
queryParamsCopy.format = "json";
// Now, let's try to get our hands on an XMLHttpRequest object.
var request = window.XMLHttpRequest ? new XMLHttpRequest() // Firefox, Safari, Opera, IE7
: window.ActiveXObject ? new ActiveXObject("Microsoft.XMLHTTP") // IE6
: false;
if (request) {
this.log.trace("catwatch2.doJSONQuery2()", "obtained request");
var f = function(parameters,method,callbacks,request,cbkdata) {
this.log.trace("Got response: ",request.responseText);
// The returned data is JSON data: parse it and add the object to the result
var resultobj = this.parseJSON(request.responseText);
this.log.trace("catwatch2.doJSONQuery2()", "JSON object is ",resultobj);
// result.push(obj);
// Call our query-chunk callback.
if(callbacks.onsuccess) {callbacks.onsuccess.call(this,resultobj,cbkdata);}
// Now... is there more work to do?
if (resultobj["query-continue"]) {
this.log.debug("catwatch2.doJSONQuery2()", "found a query-continue property");
// ... yes, there is more work to do, because
// we have a query-continue member in the reply.
// Now, the query-continue member usually contains
// just one object, but the API documentation says
// there could be two when generators are used
// (one for the generator query and one for the real query).
// We have to ensure that we use just one at a time,
// giving precedence to the one for the real query.
//
// To do that, we add all query-continue members to an
// array, in increasing order of precedence, then pop
// the last element (which has necessarily the highest
// precedence).
//
var qcList = [];
for(j in resultobj["query-continue"]) {
// Did we use a generator in querying, and is this
// the generator-related query-continue parameter?
if(parameters.generator && parameters.generator == j) {
this.log.debug("catwatch2.doJSONQuery2()","found a generator-related query-continue parameter");
// Yes... it is. This has lower priority over
// the non-generator-related query-continue,
// so put it at the beginning of the qc array.
qcList.splice(0,0,resultobj["query-continue"][j]);
} else {
this.log.debug("catwatch2.doJSONQuery2()","found a NON-generator-related query-continue parameter");
// A non-generator-related query-continue parameter.
// Put it at the end of the qc array.
qcList.push(resultobj["query-continue"][j]);
}
}
if (qcList.length > 1 ) {
this.log.info("catwatch2.doJSONQuery2()","please note that we found more than one query-continue parameter");
}
// Now, pop() the query-continue object from the end...
var qc = qcList.pop();
// ... and adjust the parameters accordingly.
var queryParamsCopy = {};
var i; for (i in parameters) { queryParamsCopy[i] = parameters[i]; };
var j; for (j in qc) { queryParamsCopy[j] = qc[j]; };
// Call ourselves recursively:
this.doJSONQuery2(queryParamsCopy,method,callbacks,cbkdata);
} else {
//
if(callbacks.oncomplete) {callbacks.oncomplete.call(this,cbkdata);}
}
}
var queryURL;
// Prepare an asynchronous request
switch(method) {
case "GET":
this.log.debug("catwatch2.doJSONQuery2()","Using a GET method");
// Compute the URL for the query.
queryURL = this.prepareQueryURL(this.getAPIURL(),queryParamsCopy);
this.log.debug("catwatch2.doJSONQuery2()","Query URL is ",queryURL);
request.open("GET", queryURL, true);
var that = this;
// Workaround for IE6, where "this" is not the XmlHttpRequest object
// within the callback.
var thisrequest = request;
request.onreadystatechange = function() {
if(thisrequest.readyState == 4) {
if(thisrequest.status == 200) {
f.call(that,parameters,method,callbacks,thisrequest,cbkdata);
} else {
if(callbacks.onerror) {callbacks.onerror.call(that,thisrequest,cbkdata);}
}
}
}
try {
request.send();
} catch (err) {
this.log.error("catwatch2.doJSONQuery2()","caught an exception while using a GET method: ",err);
if(callbacks.onerror) {callbacks.onerror(this,request,cbkdata);}
request = null;
}
break;
case "POST":
this.log.debug("catwatch2.doJSONQuery2()","using a POST method");
queryURL = this.getAPIURL();
var params = this.prepareQueryURL("",queryParamsCopy);
request.open("POST", queryURL, true);
request.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
request.setRequestHeader("Content-length", params.length);
request.setRequestHeader("Connection", "close");
var that = this;
// Workaround for IE6, where "this" is not the XmlHttpRequest object
// within the callback.
var thisrequest = request;
request.onreadystatechange = function() {
if(thisrequest.readyState == 4) {
if(thisrequest.status == 200) {
f.call(that,parameters,method,callbacks,thisrequest,cbkdata);
} else {
if(callbacks.onerror) {obj.callbacks.onerror.call(that,thisrequest,cbkdata);}
}
}
}
try {
request.send(params);
} catch (err) {
this.log.error("catwatch2.doJSONQuery2()","caught an exception while using a POST method: ",err);
if(callbacks.onerror) {callbacks.onerror(this,request,cbkdata);}
request = null;
request = null;
}
default:
throw("Bad method, must be eithe GET or POST");
}
}
return "";
},
/**
* Test if two Date objects indicate the same day. Note that
* either date (or both) can be an undefined value.
*
* @param date1 is the first date.
* @param date1 is the second date.
*
* @return true if the two dates indicate the same day, false otherwise.
* Please note that an undefined/null date is never on the
* same day as another date.
*/
areOnSameDay: function(date1, date2) {
if (date1 == undefined || date2 == undefined) return false;
if (date1.getFullYear() != date2.getFullYear()) return false;
if (date1.getMonth() != date2.getMonth()) return false;
if (date1.getDate() != date2.getDate()) return false;
return true;
},
/**
* Pad a number (or whatever) to the left for the given length
* using the given char.
*
* @param n is the number (or whatever) to be padded
* @param padlength (optional, default 2) is the pad length
* @param padchar (optional, default "0") is the pad character
*
* @return a string that it at least padlength characters long
*/
lpad: function(n,padlength,padchar) {
if (padlength == undefined) {padlength = 2}
if (padchar == undefined) {padchar="0"}
var x = "" + n;
while(x.length < padlength) { x = padchar + x }
return x;
},
/**
* Ad-hoc function to format a Date object (or a timestamp)
*/
formatDateAndTime: function(date) {
var result = { date: "", time: "" };
var d = date;
if ( ! d instanceof Date ) {
d = new Date(date);
}
result.date = "" + d.getFullYear() + "-" + this.lpad(d.getMonth() + 1,2,"0") + "-" + this.lpad(d.getDate() ,2,"0");
result.time = "" + this.lpad(d.getHours(),2,"0") + ":" + this.lpad(d.getMinutes(),2,"0");
return result;
},
/**
* Parses most valid ISO 8601 date and time strings into a Date object.
*
* This function should NOT be used to test if a string is a valid ISO 8601 date.
*
* It is a bit more flexible than Date.parse(), in that it accepts more formats.
* OTOH, it does not handle time fractions (for example, "2010-12-23T13:20.5Z" is not recognized
* as a valid date).
* Please note that a time of 24:00:00 is allowed, meaning the midnight of the
* following day (i.e. 2010-12-31T24:00:00 is equivalent to 2011-01-01T00:00:00)
*
* When only a date is specified without a time, default to the UTC midnight
* (i.e. 2010-12-25 is equivalent to 2010-12-25T00:00:00Z)
*/
parseISODate: function(str) {
var result;
var r = /^(\d{4}|[-+]\d{6})-?(0[1-9]|1[0-2])(-?(0[1-9]|[12][0-9]|3[01]))?([ T]([01][0-9]|2[0-4])(:?([0-5][0-9])(:?([0-5][0-9])?)?)?)?(Z|([-+]([01][0-9]|2[0-3]))(:?([0-5][0-9]))?)?$/.exec(str);
if (r) {
var b = { yyyy: 0, mm: 1, dd: 1, hh: 0, mi: 0, ss: 0, hhoffset: 0, mioffset: 0, msecoffset: 0};
b.yyyy=+r[1];b.mm =+r[2];
var useUTCMidnight = false;
if(r[4]){b.dd=+r[4];if(r[6]){b.hh=+r[6];if(r[8]){b.mi=+r[8];if(r[10])b.ss=+r[10];}}
else {useUTCMidnight = true}
} else {useUTCMidnight = true}
if ( useUTCMidnight ) {
result = new Date(Date.UTC(b.yyyy,b.mm - 1,b.dd,b.hh,b.mi,b.ss,0));
} else if (r[11] && r[11]=="Z") {
result = new Date (Date.UTC(b.yyyy,b.mm - 1,b.dd,b.hh,b.mi,b.ss,0));
} else if (r[12]) {
b.hhoffset = +r[12];
if(r[15]) b.mioffset = +r[15];
b.msecoffset = (b.hhoffset * 3600000)+(b.mioffset*60000);
result = new Date(Date.UTC(b.yyyy,b.mm - 1,b.dd,b.hh,b.mi,b.ss,0) - b.msecoffset);
} else {
result = new Date(b.yyyy,b.mm - 1,b.dd,b.hh,b.mi,b.ss,0);
}
}
return result;
},
/**
* Replaces the special characters '&','<','>' and '"' with their
* corresponding HTML entities, so a string can safely be embedded
* in an HTML document.
*
* @param str is the string to be quoted
*
* @return the quoted string
*/
toStringLiteral: function(str) {
return str.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
},
/**
* Replaces the special characters '&','<','>' and '"' with their
* corresponding HTML entities, and surround the result with
* double quotes, so it can be used as the value for an element's
* attribute.
*
* @param str is the string to be quoted
*
* @return the quoted string, surrounded by double quotes.
*
*/
toQuotedStringLiteral: function(str) {
return '"' + this.toStringLiteral(str) + '"';
},
/**
* Take a simple pattern (where the only wildcard is the asterisk)
* and return an object with the equivalent regexp and the prefix
* to search (i.e. everything up to the first asterisk)
*/
asteriskPatternToRegexp: function(pattern) {
var charstoquote="()[]{}*.?+\\|^$";
var prefix = "";
var addtoprefix = true;
var s = pattern.split("");
// Our regexp is anchored at the start...
var re = "^";
// Examine every char...
var i; for(i in s) {
if (s[i] == "*" ) {
// This is an asterisk: its regexp equivalent is ".*".
// Also, do not add chars to the prefix anymore.
addtoprefix=false;
re += ".*";
} else if (charstoquote.indexOf(s[i]) >= 0) {
// This char has a special meaning for regexp syntax,
// so it must be quoted.
re += "\\" + s[i];
} else {
// An ordinary char.
re += s[i];
}
// Add the char to the prefix as well,
// unless we encountered an asterisk before...
if(addtoprefix) prefix += s[i];
}
// Our regexp is anchored at the end as well.
re += "$";
return { regexp: re, prefix: prefix }
},
/**
* Given a pattern for category names (without the
* "Category:" prefix), return an array of categories
* matching that pattern (without the "Category:" prefix).
*
* The pattern must start with at least three letters
* (i.e. "abc*xyz" is ok, "ab*xyz" is not, neither is
* "*foobarbaz"); bad patterns are returned unexpanded
*/
expandCategoryPattern: function(pattern) {
var result = [];
var r = this.asteriskPatternToRegexp(pattern);
if (r.prefix.length < 3) {
// We don't support short prefixes, because it would be
// too onerous on the server side.
this.log.warn("expandCategoryPattern()","pattern",pattern,"expands to a prefix that is too short for searches");
this.log.warn("expandCategoryPattern()","returning an empty category list");
// Return the pattern unexpanded
result.push(pattern);
return result;
}
re = new RegExp(r.regexp,"");
try {
var queryParams = {
action: "query",
rawcontinue: "",
list: "allcategories",
acprefix: r.prefix,
aclimit: 500,
acprop: ""
}
var data = this.doJSONQuery(queryParams,"GET");
var catname;
var i; for(i in data) {
var d = data[i].query.allcategories;
var j; for(j in d) {
catname = d[j]["*"];
if(re.test(catname)) {
this.log.debug("expandCategoryPattern()","found matching category",catname);
result.push(catname);
} else {
this.log.debug("expandCategoryPattern()","category",catname, "does not match pattern, discarding");
}
}
}
} catch(err) {
this.log.warn("expandCategoryPattern()","caught an exception",err);
}
return result;
},
/**
* Parse a category name and return an object with two properties:
*
* - prefixless is the category name without any "Category:" prefix
*
* - withprefix is the category name with the canonical "Category:"
* prefix
*
* - prefix is the canonical category prefix including the ":"
* (i.e. "Category:");
*/
normalizeCategoryName: function(category) {
var prefixes = this.getCategoryNamespacePrefixes();
// Strip leading and trailing whitespace
category.replace(/^\s*|\s*$/g,"");
// Strip the "Category:" prefix, if any
var prefixless = category;
var p; for(p in prefixes) {
if(category.indexOf(prefixes[p]+":") == 0) {
prefixless = category.substr(prefixes[p].length + 1);
break;
}
}
var withprefix = prefixes[0] + ":" + prefixless;
return {
prefix: prefixes[0] + ":",
prefixless: prefixless,
withprefix: withprefix
};
},
/**
* Return the URL of the MediaWiki API
*/
getAPIURL: function() {
if(this.apiURL) return this.apiURL;
if (this.useRelativeURLs) {
return "/w/api.php";
} else {
return this.baseURL + "/w/api.php";
}
},
/**
* Return the HTML code for a link to a certain page of the wiki.
*
* @param pageTitle is the full title of the page to be linked. When empty, please
* provide an href parameter via extraParams below.
*
* @param label is the label to be displayed on the link
*
* @param extraParams is an object specifying properties of the
* HTML "a" element. Example:
* { style: "color:red;", title: "Title of the anchor"}
*
* @return the HTML code
*/
prepareHTMLLinkToPage: function(pageTitle,label,extraParams) {
if(label == undefined || label == null || label == "") {
label = pageTitle;
}
var base = "/w/index.php";
if (! this.useRelativeURLs) {
base = this.baseURL + base;
}
var result = '<a';
if(pageTitle != "") {
result + result + ' href="' + base + "?title=" + encodeURIComponent(pageTitle) + '"';
}
if (extraParams) {
var i; for(i in extraParams) {
result = result + " " + i + "=" + this.toQuotedStringLiteral(extraParams[i]);
}
}
result = result + '>' + this.toStringLiteral(label) + "</a>";
return result;
},
/**
* Given a page object, return an object with all the HTML links to various things related to that page.
*/
prepareHTMLLinks: function(page) {
var result = { diff: "", hist: "", page: "", user: "", usertalk: "", usercontribs: "", category: "" };
var base = "/w/index.php";
if (! this.useRelativeURLs) {
base = this.baseURL + base;
}
var encodedTitle = encodeURIComponent(page.title);
// TODO: i18n: "diff"
if (page.revisions[0].parentid == 0) {
// This is a new file, it has no previous version.
result.diff = "diff";
} else {
result.diff = '<a href="' +
base +
"?title=" + encodedTitle +
"&diff=" + encodeURIComponent(page.revisions[0].revid) +
"&oldid=" + encodeURIComponent(page.revisions[0].parentid) +
'">diff</a>';
}
// TODO: i18n: "hist"
result.hist = '<a href="' +
base +
"?title=" + encodedTitle +
'&action=history">hist</a>';
result.page = '<a href="' +
base +
"?title=" + encodedTitle + '">' +
this.toStringLiteral(page.title) +
"</a>";
result.user = '<a href="' +
base +
"?title="+ encodeURIComponent("User:"+page.cw2user) +
'">' +
this.toStringLiteral(page.cw2user) +
"</a>";
// TODO: i18n: the "User:" prefix, "talk"
result.usertalk = '<a href="' +
base +
"?title=" +
encodeURIComponent("User_talk:"+page.cw2user) +
'">' +
"talk</a>";
// TODO: i18n: the "User:" prefix, "contrib"
result.usercontribs = '<a href="' +
base +
"?title=" +
encodeURIComponent("Special:Contributions/"+page.cw2user) +
'">contribs</a>';
result.category = '<a href="' +
base +
"?title="+ encodeURIComponent(page.cw2category) + '" ' +
'title=' + this.toQuotedStringLiteral(page.cw2category) + '>' +
// this.toStringLiteral(page.cw2category) +
"cat" +
"</a>";
return result;
},
/**
* Install this gadget (by putting a link next to the watched items);
*/
install: function() {
// If log4javascript is available, use it to do our logging.
// Otherwise, do without it (via stub functions, see this.log).
try {
catwatch2.log = log4javascript.getDefaultLogger();
} catch (err) {};
var specialWatchlist = document.getElementById('pt-watchlist');
if(specialWatchlist) {
var cw2link = document.createElement("li");
// TODO: i18n tooltip text
cw2link.innerHTML = '<a href="#" onclick="catwatch2.openPopup()" title=' + this.toQuotedStringLiteral("The Catwatch2 gadget, version " + this.version) + '>catwatch2</a>';
specialWatchlist.parentNode.insertBefore(cw2link,specialWatchlist);
}
},
/**
* Create the Catwatch2 popup window.
*/
createPopup: function() {
// The content of our popup window (version with HTML tables, which is usable also on IE6...)
var popupContent = [
'<table border="0" width="100%" style="background-color: #0000FF; color: white;">',
' <tr>',
' <td width="*" >' + this.prepareHTMLLinkToPage("","Catwatch2 (V" + this.version + ")", { href: "http://commons.wikimedia.org/w/index.php?title=User_talk%3AIanezz%2FCatwatch2.js", style: "color: white; font-weight: bold;", target: "_blank" } ) + '</td>',
' <td><a id="cw2closeButton" href="#" onclick="catwatch2.closePopup()" style="float:right; background-color: #DDDDDD; color: black; border: 3px outset #DDDDDD; text-decoration: none;"> X </a></td>',
' </tr>',
'</table>',
'<table border="0" cellpadding="5" width="100%">',
' <tr valign="top">',
' <td style="background-color: #DDDDDD; color: black;" width="300" >',
' <div>',
' <form>',
' <div>Categories:<br><span style="font-size: 50%">(one per line, may include "*")</span><br>',
' <textarea id="cw2watchedCategories" rows="10" cols="20"></textarea><br>',
' <input type="button" value="Expand * in names" onclick="catwatch2.doExpandCategories(document)"></input>',
' </div>',
' <div>Search depth:<br>',
' <input id="cw2levels0" type="radio" name="cw2levels" value="0" checked="checked">0</input> ',
' <input id="cw2levels1" type="radio" name="cw2levels" value="1" >1</input> ',
' <input id="cw2levels2" type="radio" name="cw2levels" value="2" >2</input> ',
' <input id="cw2levelsotherradio" type="radio" name="cw2levels" value="other" ></input>',
' <input id="cw2levelsother" type="text" size="2" value="3"></input></div>',
' <hr>',
' <div>Days ago:<br><input id="cw2days" type="text" value="30"></input></div>',
' <div>Ignore user: <br><input id="cw2ignoreUser" type="text" value=""></input></div>',
' <table border="0">',
' <tr>',
' <td>Bots:</td>',
' <td><input id="cw2botseither" type="radio" name="cw2bots" value="either" checked="checked"></input>Either</td>',
' <td><input id="cw2botsonly" type="radio" name="cw2bots" value="only"></input>Yes</td>',
' <td><input id="cw2botsexcept" type="radio" name="cw2bots" value="except"></input>No</td>',
' </tr>',
' <tr>',
' <td>New:</td>',
' <td><input id="cw2neweither" type="radio" name="cw2new" value="either" checked="checked"></input>Either</td>',
' <td><input id="cw2newonly" type="radio" name="cw2new" value="only"></input>Yes</td>',
' <td><input id="cw2newexcept" type="radio" name="cw2new" value="except"></input>No</td>',
' </tr>',
' <tr>',
' <td>Minor:</td>',
' <td><input id="cw2minoreither" type="radio" name="cw2minor" value="either" checked="checked"></input>Either</td>',
' <td><input id="cw2minoronly" type="radio" name="cw2minor" value="only"></input>Yes</td>',
' <td><input id="cw2minorexcept" type="radio" name="cw2minor" value="except"></input>No</td>',
' </tr>',
' </table>',
' <input type="button" value="Search" onclick="catwatch2.doScan2(document)"></input>',
' </form>',
' </div>',
' </td>',
' <td width="*"><div id="cw2bodyContent" style="background-color: white;"> </div></td>',
' </tr>',
'</table>'
];
var dom = {};
dom.doc = document;
dom.popupdiv = dom.doc.createElement("div");
dom.popupparent = dom.doc.getElementById("bodyContent");
if (! dom.popupparent) {
dom.popupparent = dom.doc.getElementsByTagName("body")[0];
}
dom.popupdiv.style.backgroundColor = "#DDDDDD";
dom.popupdiv.style.borderColor = "#000000";
dom.popupdiv.style.borderStyle = "solid";
dom.popupdiv.style.borderWidth = "2px";
dom.popupdiv.style.padding = 0;
dom.popupdiv.style.margin = "2px";
dom.popupdiv.style.position = "absolute";
dom.popupdiv.style.left = "0px";
dom.popupdiv.style.right = "0px";
dom.popupdiv.style.top = "0px";
dom.popupdiv.style.zIndex = 999;
dom.popupparent.appendChild(dom.popupdiv);
dom.popupdiv.innerHTML = popupContent.join("\n");
dom.bc = dom.doc.getElementById("cw2bodyContent");
dom.categoriesTextArea = dom.doc.getElementById("cw2watchedCategories");
dom.categoriesTextArea.value = this.watchedCategories.join("\n");
return dom.popupdiv;
},
/**
* Show the Catwatch2 popup window, creating it first if needed.
*/
openPopup: function() {
if (! this.popup) {
this.popup = this.createPopup();
}
this.popup.style.display = "block";
},
/**
* Hides the Catwatch2 popup window
*/
closePopup: function() {
if (this.popup) {
this.popup.style.display = "none";
}
},
/**
* Descend the given categories up to the given depth,
* listing the last revision of each member.
*/
catwalk2: function(categories,depth,userobject,usercallbacks) {
var staticdata = {
visited: {},
descended: {},
pendingrequests: 0,
userobject: userobject,
usercallbacks: usercallbacks
};
staticdata.pendingrequests += categories.length
var i; for(i in categories) {
var category = categories[i];
var newuserobj = {
parentcategory: category,
childcategories: [],
pages: [],
depth: depth,
staticdata: staticdata
}
this.catwalk2Impl(category,newuserobj,false);
}
},
/**
* This is the recursive part of catwalk2().
*/
catwalk2Impl: function(category,cbkdata,incrementRequests) {
var callbacks = {
onsuccess: function(queryresultobj,cbkdata) {
var usercallbacks = cbkdata.staticdata.usercallbacks;
// Cycle on all pages of this partial result
if (!(queryresultobj.query && queryresultobj.query.pages)) {
this.log.warn("catwalk2Impl()","results are missing from queryresultobj...");
return;
}
var b = this.getBotUsers();
var i; for(i in queryresultobj.query.pages) {
// Fill in some data
var page = queryresultobj.query.pages[i];
page.cw2category = cbkdata.parentcategory;
page.cw2ts = this.parseISODate(page.revisions[0].timestamp).getTime();
page.cw2user = page.revisions[0].user;
page.cw2isnew = (page.revisions[0].parentid == 0);
page.cw2isminor = ! (page.revisions[0].minor == undefined);
page.cw2isbot = false; if (b[page.cw2user]) page.cw2isbot = true;
// Save this page for the cache, regardless of everything else.
cbkdata.pages.push(page);
// Now, if this page has not already been visited before...
if(!(cbkdata.staticdata.visited[page.title])) {
// ... mark it as visited...
cbkdata.staticdata.visited[page.title] = true;
// Call our callbacks, if any
if(usercallbacks.onnewpage) { usercallbacks.onnewpage.call(this,page,cbkdata.staticdata.userobject) };
// If the page is a category (namespace 14)...
if(page.ns == 14) {
// ...remember its title for later.
cbkdata.childcategories.push(page.title);
}
}
}
},
onerror: function(request,cbkdata) {
this.log.error("Some error occurred....");
var usercallbacks = cbkdata.staticdata.usercallbacks;
// Call our callbacks, if any
if(usercallbacks.onerror) {
usercallbacks.onerror.call(this,
request,
cbkdata.parentcategory,
cbkdata.staticdata.userobject);
}
},
oncomplete: function(cbkdata) {
var usercallbacks = cbkdata.staticdata.usercallbacks;
this.log.debug("COMPLETED category",cbkdata.parentcategory);
// Save the result in our cache for later.
this.querycache.set(cbkdata.parentcategory,cbkdata.pages);
// If we have to descend a level...
if (cbkdata.depth > 0) {
// ... let's cycle on children categories...
var i; for(i in cbkdata.childcategories) {
var title = cbkdata.childcategories[i];
// ... and if we didn't descend this category before...
if (! (cbkdata.staticdata.descended[title])) {
// ... well, mark it as descended and descend it.
cbkdata.staticdata.descended[title] = true;
this.log.debug("catwalk2Impl()","descending category",title);
var newuserobj = {
parentcategory: title,
childcategories: [],
pages: [],
depth: cbkdata.depth - 1,
staticdata: cbkdata.staticdata
}
this.catwalk2Impl(title,newuserobj,usercallbacks,true);
}
}
}
cbkdata.staticdata.pendingrequests--;
this.log.debug("pending requests are ",cbkdata.staticdata.pendingrequests);
if(usercallbacks.oncompleted) {
usercallbacks.oncomplete.call(this,
cbkdata.parentcategory,
cbkdata.staticdata.userobject);
}
if (cbkdata.staticdata.pendingrequests == 0) {
if(usercallbacks.onallcompleted) {
usercallbacks.onallcompleted.call(this,
cbkdata.staticdata.userobject);
}
}
}
}
if (incrementRequests) {
cbkdata.staticdata.pendingrequests++;
}
var r = this.querycache.get(category);
if (r == undefined) {
var parameters= {
action: "query",
rawcontinue: "",
generator: "categorymembers",
gcmtitle: category,
gcmlimit: 500,
prop: "revisions",
rvprop: "timestamp|user|ids|flags"
}
this.log.debug("starting query for category",category);
this.doJSONQuery2(parameters,"GET",callbacks,cbkdata);
} else {
this.log.debug("catwalk2Impl()","using cached results");
if(callbacks.onsuccess) {callbacks.onsuccess.call(this,{query: {pages: r}},cbkdata);}
if(callbacks.oncomplete) {callbacks.oncomplete.call(this,cbkdata);}
}
},
expandCategories: function(categories,withprefix,onbadpattern) {
var result = [];
if(withprefix == undefined) withprefix = true;
var i; for (i in categories) {
var x = this.normalizeCategoryName(categories[i]);
if (x.prefixless.indexOf("*") >= 0) {
// The category is actually a pattern that
// has to be expanded
var expanded = this.expandCategoryPattern(x.prefixless);
if(x.prefixless == expanded) {
if(onbadpattern) { onbadpattern.call(this,x.prefixless) }
}
var j; for(j in expanded) {
if(withprefix) {
result.push(x.prefix + expanded[j]);
} else {
result.push(expanded[j]);
}
}
} else {
if(withprefix) {
result.push(x.withprefix);
} else {
result.push(x.prefixless);
}
}
}
return result;
},
/**
* Start a search
*/
doScan2: function(doc) {
var userobject = {};
userobject.dom = { doc: doc };
userobject.dom.bc = userobject.dom.doc.getElementById("cw2bodyContent");
userobject.dom.categoriesTextArea = userobject.dom.doc.getElementById("cw2watchedCategories");
userobject.dom.daysAgoText = userobject.dom.doc.getElementById("cw2days");
userobject.dom.ignoreUserText = userobject.dom.doc.getElementById("cw2ignoreUser");
//
// Workaround for IE, where getElementsByName() does not work.
//
// userobject.dom.botsradiobuttons = userobject.dom.doc.getElementsByName("cw2bots");
userobject.dom.botsradiobuttons = [];
userobject.dom.botsradiobuttons.push(userobject.dom.doc.getElementById("cw2botseither"));
userobject.dom.botsradiobuttons.push(userobject.dom.doc.getElementById("cw2botsonly"));
userobject.dom.botsradiobuttons.push(userobject.dom.doc.getElementById("cw2botsexcept"));
// userobject.dom.newradiobuttons = userobject.dom.doc.getElementsByName("cw2new");
userobject.dom.newradiobuttons = [];
userobject.dom.newradiobuttons.push(userobject.dom.doc.getElementById("cw2neweither"));
userobject.dom.newradiobuttons.push(userobject.dom.doc.getElementById("cw2newonly"));
userobject.dom.newradiobuttons.push(userobject.dom.doc.getElementById("cw2newexcept"));
// userobject.dom.minorradiobuttons = userobject.dom.doc.getElementsByName("cw2minor");
userobject.dom.minorradiobuttons = [];
userobject.dom.minorradiobuttons.push(userobject.dom.doc.getElementById("cw2minoreither"));
userobject.dom.minorradiobuttons.push(userobject.dom.doc.getElementById("cw2minoronly"));
userobject.dom.minorradiobuttons.push(userobject.dom.doc.getElementById("cw2minorexcept"));
// userobject.dom.depthradiobuttons = userobject.dom.doc.getElementsByName("cw2levels");
userobject.dom.depthradiobuttons = [];
userobject.dom.depthradiobuttons.push(userobject.dom.doc.getElementById("cw2levels0"));
userobject.dom.depthradiobuttons.push(userobject.dom.doc.getElementById("cw2levels1"));
userobject.dom.depthradiobuttons.push(userobject.dom.doc.getElementById("cw2levels2"));
userobject.dom.depthradiobuttons.push(userobject.dom.doc.getElementById("cw2levelsotherradio"));
userobject.dom.depthtext = userobject.dom.doc.getElementById("cw2levelsother");
var depth;
var i; for (i in userobject.dom.depthradiobuttons) {
if (userobject.dom.depthradiobuttons[i].checked) {
depth = userobject.dom.depthradiobuttons[i].value;
break;
}
}
if (depth == "other" ) {
depth = userobject.dom.depthtext.value;
}
userobject.displaypolicy = {};
var i; for (i in userobject.dom.botsradiobuttons) {
if (userobject.dom.botsradiobuttons[i].checked) {
userobject.displaypolicy.bots = userobject.dom.botsradiobuttons[i].value;
break;
}
}
var i; for (i in userobject.dom.newradiobuttons) {
if (userobject.dom.newradiobuttons[i].checked) {
userobject.displaypolicy.newpage = userobject.dom.newradiobuttons[i].value;
break;
}
}
var i; for (i in userobject.dom.minorradiobuttons) {
if (userobject.dom.minorradiobuttons[i].checked) {
userobject.displaypolicy.minor = userobject.dom.minorradiobuttons[i].value;
break;
}
}
var days = userobject.dom.daysAgoText.value;
if (days=="") {
userobject.displaypolicy.tslimit = undefined
} else {
var d = new Date();
userobject.displaypolicy.tslimit = d.getTime() - (86400000 * days);
}
var categories = userobject.dom.categoriesTextArea.value.split("\n");
var prefixes = this.getCategoryNamespacePrefixes();
if (prefixes.length == 0) {
// TODO: i18n: troubles contacting the server...
userobject.dom.bc.innerHTML = "Error querying the server for the category prefixes"
return;
}
var userWarned = false;
var categoriestosearch = this.expandCategories(categories,true, function(badpattern) {
if (! userWarned) {
alert('Bad pattern "' + badpattern + '", it must specify at least three characters before the first "*"');
userWarned = true;
}
});
var u = userobject.dom.ignoreUserText.value;
u = u.replace(/^\s*|\s*$/g,"");
userobject.displaypolicy.ignoreUser = u;
// Clear the results
userobject.dom.bc.innerHTML = 'Pages: <span id="cw2progresscounter"> </span>';
userobject.dom.progresscounter = userobject.dom.doc.getElementById("cw2progresscounter");
userobject.querykey="" + depth+ "," + categoriestosearch.join("|");
userobject.counter = 0;
userobject.allpages = [];
if(userobject.querykey == this.lastQueryResult.querykey) {
// The query parameters didn't change? Go straight displaying the last result
// again
userobject.allpages = this.lastQueryResult.value;
this.doScan2DisplayResults(userobject);
} else {
// The query parameters changed, let's do it again
this.catwalk2(categoriestosearch,depth,userobject,{
onnewpage: function(resultobj,userobject) {
userobject.allpages.push(resultobj);
userobject.counter++
if (userobject.counter % 20 == 0) {
userobject.dom.progresscounter.innerHTML = "" +userobject.counter;
}
},
onallcompleted: function(userobject) {
this.log.debug("COMPLETED EVERYTHING.","We got a total of",userobject.allpages.length,"pages");
this.doScan2DisplayResults(userobject);
}
});
}
},
/**
* Display the results of a search
*/
doScan2DisplayResults: function(userobject) {
var dom = userobject.dom;
var pages = userobject.allpages;
if(userobject.querykey != this.lastQueryResult.querykey) {
// Since we are not using a cached result,
// we have to sort it and store for later.
// TODO: i18n
dom.bc.innerHTML ="sorting...";
pages.sort(function(a,b) {
return b.cw2ts - a.cw2ts;
});
// Store sorted results for later.
this.lastQueryResult.querykey = userobject.querykey;
this.lastQueryResult.value = pages;
}
dom.bc.innerHTML ="";
dom.totalizer = dom.doc.createElement("div");
dom.bc.appendChild(dom.totalizer);
var currentDate, previousDate;
var counters = {
total: { total: 0, newpages: 0, bots: 0, minor: 0},
displayed: {total: 0, newpages: 0, bots: 0, minor: 0 }
};
var i; for(i in pages) {
var page = pages[i];
// Count this page among the totals
counters.total.total++;
if (page.cw2isminor) counters.total.minor++;
if (page.cw2isbot) counters.total.bots++;
if (page.cw2isnew) counters.total.newpages++;
if (userobject.displaypolicy.tslimit && page.cw2ts < userobject.displaypolicy.tslimit) {
// Skip this page, because the edit was done before the time limit.
continue;
}
if(page.cw2user == userobject.displaypolicy.ignoreUser) {
// Skip this page, because the edit was done by the user to ignore.
continue;
}
// Skip this page if the display policy requires us to do so.
if (userobject.displaypolicy.minor == "only" && !page.cw2isminor) { continue };
if (userobject.displaypolicy.minor == "except" && page.cw2isminor) { continue };
if (userobject.displaypolicy.bots == "only" && !page.cw2isbot) { continue };
if (userobject.displaypolicy.bots == "except" && page.cw2isbot) { continue };
if (userobject.displaypolicy.newpage == "only" && !page.cw2isnew) { continue };
if (userobject.displaypolicy.newpage == "except" && page.cw2isnew) { continue };
try { previousDate = currentDate; } catch(err) {}
currentDate = new Date(page.cw2ts);
if (! this.areOnSameDay(currentDate, previousDate)) {
dom.h3 = dom.doc.createElement("h3");
dom.h3.innerHTML = this.formatDateAndTime(currentDate).date;
dom.bc.appendChild(dom.h3);
dom.list = dom.doc.createElement("ul");
dom.bc.appendChild(dom.list);
}
dom.listItem = dom.doc.createElement("li");
dom.listItem.innerHTML = this.formatPageRow(page);
dom.list.appendChild(dom.listItem);
counters.displayed.total++;
if (page.cw2isminor) counters.displayed.minor++;
if (page.cw2isbot) counters.displayed.bots++;
if (page.cw2isnew) counters.displayed.newpages++;
}
// TODO: i18n
dom.totalizer.innerHTML="<b>Totals</b>: " +
"displaying " + counters.displayed.total + " pages (" +
counters.displayed.bots + " bots, " +
counters.displayed.newpages + " new, " +
counters.displayed.minor + " minor edits)" +
" of " + counters.total.total + " (" +
counters.total.bots + " bots, " +
counters.total.newpages + " new, " +
counters.total.minor + " minor edits)";
},
doExpandCategories: function(doc) {
var categoriesTextArea = doc.getElementById("cw2watchedCategories");
var categories = categoriesTextArea.value.split("\n");
var userWarned = false;
var categoriestosearch = this.expandCategories(categories,false,function(badpattern) {
if (! userWarned) {
alert('Bad pattern "' + badpattern + '", it must specify at least three characters before the first "*"');
userWarned = true;
}
});
categoriesTextArea.value = categoriestosearch.join("\n");
},
/**
* Query (synchronously) the server to get the prefixes for
* namespace 14 (categories).
*
* @Return an array of prefixes: the first element
* is the canonical prefix (i.e. "Category")
*/
getCategoryNamespacePrefixes: function() {
if(this.categorynamespaceprefixes) {
return this.categorynamespaceprefixes;
}
var result = [];
// http://it.wikipedia.org/w/api.php?action=query&meta=siteinfo&siprop=general|namespaces|namespacealiases|statistics&format=jsonfm
try {
var queryParams = {
action: "query",
rawcontinue: "",
meta: "siteinfo",
siprop: "namespaces"
}
var data = this.doJSONQuery(queryParams,"GET");
this.log.debug("got",data);
result.push(data[0].query.namespaces["14"]["canonical"]);
result.push(data[0].query.namespaces["14"]["*"]);
this.categorynamespaceprefixes = result;
} catch (err) {
this.log.error("caught an exception",err);
}
return result;
},
/**
* Query the server and return an object where each bot username is a key.
*/
getBotUsers: function() {
if(this.botusernames) {
return this.botusernames;
}
this.log.debug("getBotUsers()","about to query...");
var result = {};
var queryParams = {
action: "query",
rawcontinue: "",
list: "allusers",
augroup: "bot",
aulimit: 400
}
var data = this.doJSONQuery(queryParams,"GET");
this.log.debug("got",data);
var i;
for(i in data) {
var j;
for(j in data[i].query.allusers) {
var username=data[i].query.allusers[j].name;
result[username]=true;
}
}
this.botusernames = result;
return result;
},
/**
* Given a page object, prepare the HTML code about that page, to be used when displaying the results
*/
formatPageRow: function(page) {
var d = new Date(page.cw2ts);
var links = this.prepareHTMLLinks(page);
var result= "<div>(" + links.diff + " | " + links.hist + " | " + links.category + ") . . ";
if (page.cw2isnew) {
result=result+"<b>N</b> ";
}
if (page.cw2isbot) {
// This user is a bot...
result=result+"<b>b</b> ";
}
if (page.cw2isminor) {
result=result+"<b>m</b> ";
}
result = result + links.page + "; " + this.formatDateAndTime(d).time + " . . " +
links.user + " ("+links.usertalk + " | " + links.usercontribs + ")</div>";
return result;
},
/**
* A cache for queries
*/
querycache: new SimpleCache({
// 900 seconds
maxagemillisec: 900000,
highwatermark: 3000
}),
// Cache just for the last query result.
lastQueryResult: {querykey: "", value: ""},
// Internal stuff: when set to true, we ask for the BrowserUniversalRead privilege
// (this makes sense only on Gecko-based browsers, like Mozilla Firefox) to bypass
// the same-origin rule (so this script can be loaded from an HTML file coming from
// everywhere and still be able to open XmlHttpRequest objects to query a certain wiki).
askPrivilege: false,
// Internal stuff: when false, build all links using the baseURL below. Otherwise, use relative links.
// Should obviously be false if the page loading this script is not a page of a MediaWiki installation
// (i.e. a local HTML file)
useRelativeURLs: true,
// Internal stuff: this is used only when useRelativeURLs is false to build full URLs
// (i.e. when this script is not loaded as an userscript). And since the probability that
// wgServer is undefined in such cases, we completely ignore it.
baseURL: "http://commons.wikimedia.org",
// Declare a log object with stub functions for log4javascript, for when it's not available.
// If you want logging, just load log4javascript.js (from somewhere) before loading this script.
log: {
trace: function () {},
debug: function () {},
info: function () {},
warn: function () {},
error: function () {},
fatal: function () {}
}
}
try {
hookEvent('load', function () {
catwatch2.install()
});
} catch (err) {
catwatch2.askPrivilege = true;
catwatch2.useRelativeURLs = false;
}