Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
//
// 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
    },

    /**
     * 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;">&nbsp;X&nbsp;</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>&nbsp;',
            '               <input id="cw2levels1" type="radio" name="cw2levels" value="1" >1</input>&nbsp;',
            '               <input id="cw2levels2" type="radio" name="cw2levels" value="2" >2</input>&nbsp;',
            '               <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;">&nbsp;</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">&nbsp;</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;
}