/* ] ::: API ::: ] */

/* <pre><nowiki> */

//======================================================================
//## util/lang.js 

//------------------------------------------------------------------------------
//## Object

// NOTE: these do _not_ break for (foo in bar)

/** Object helper functions */
var Objects = {
    /** create an Object from a prototype */
    object: function(obj) {
        function F() {}
        F.prototype = obj;
        return new F();
    },
    
    /** copies an Object's properties into an new Object */
    copyOf: function(obj) {
        var out = {};
        for (var key in obj)
                if (obj.hasOwnProperty(key))    
                        out = obj;
        return out;
    },
    
    /** copies an object's properties into another object */
    copySlots: function(source, target) {
        for (var key in source)
                if (source.hasOwnProperty(key)) 
                        target = source;
    },
    
    /** an object's own property names as an Array */
    ownSlots: function(obj) {
        var out = ;
        for (key in obj)
                if (obj.hasOwnProperty(key))
                        out.push(obj);
    },
    
    /** returns an object's slots as an Array of Pairs */
    toPairs: function(obj) {
        var out = ;
        for (var key in obj)
                if (obj.hasOwnProperty(key))
                        out.push(]);
        return out;
    },
    
    /** creates an Object from an Array of key/value pairs, the last Pair for a key wins */
    fromPairs: function(pairs) {
        var out = {};
        for (var i=0; i<pairs.length; i++) {
            var pair    = pairs;
            out]    = pair;
        }
    }//,
};

//------------------------------------------------------------------------------
//## Array 

// NOTE: these _do_ break for each (foo in someArray)

/** can be used to copy a function's arguments into a real Array */
Array.make = function(args) {
    return Array.prototype.slice.apply(args);
};

/** removes an element */
Array.prototype.remove = function(element) {
    var index   = this.indexOf(element);
    if (index === -1)   return false;
    this.splice(index, 1);
    return true;
};

/** whether this array contains an element */
Array.prototype.contains = function(element) {
    return this.indexOf(element) !== -1;
};


/** flatten an Array of Arrays into a simple Array */
Array.prototype.flatten = function() {
    var out = ;
    this.forEach(function(element) { 
        out = out.concat(element); 
    });
    return out;
};


/** map every element to an Array and concat the resulting Arrays */
Array.prototype.flatMap = function(func, thisVal) {
    var out = ;
    this.forEach(function(element) {
        out = out.concat(func.call(thisVal, element));
    });
    return out;
};

/** return a new Array with a separator inserted between every element of the Array */
Array.prototype.infuse = function(separator) {
    var out = ;
    for (var i=0; i<this.length; i++) {
        out.push(this);
        out.push(separator);
    }
    out.pop();
    return out;
};

/** use a function to extract keys and build an Object */
Array.prototype.indexWith = function(keyFunc) {
    var out = {};
    for (var i=0; i<this.length; i++) {
        var item    = this;
        out  = item;
    }
    return out;
};

//------------------------------------------------------------------------------
//## Function

/** the unary identiy function */
Function.identity = function(x) { return x; }

/** create a constant Function */
Function.constant = function(c) { return function(v) { return c; } }

/** create a Function calling this Function with a fixed this */
/*Function.prototype.bind = function(thisObject) {
    var self    = this; // == arguments.callee
    return function() {
        return self.apply(thisObject, arguments);
    };
};*/

/** create a Function calling this Function with fixed first arguments */
Function.prototype.fix = function() {
    var self    = this; // == arguments.callee
    var args    = Array.prototype.slice.apply(arguments);
    return function() {
        return self.apply(this, args.concat(Array.prototype.slice.apply(arguments)));
    };
};

//------------------------------------------------------------------------------
//## String

/** remove whitespace from both ends */
/*String.prototype.trim = function() {
    return this.replace(/^\s+|\s+$/g, "");
};*/

/** return text without prefix or null */
String.prototype.scan = function(s) {
    return this.substring(0, s.length) === s
            ? this.substring(s.length)
            : null;
};

/** return text without prefix or null */
String.prototype.scanNoCase = function(s) {
    return this.substring(0, s.length).toLowerCase() === s.toLowerCase()
            ? this.substring(s.length)
            : null;
};

/** true when the string starts with the pattern */
String.prototype.startsWith = function(s) {
    return this.indexOf(s) === 0;
};

/** true when the string ends in the pattern */
String.prototype.endsWith = function(s) {
    return this.lastIndexOf(s) === this.length - s.length;
};

/** escapes characters to make them usable as a literal in a RegExp */
String.prototype.escapeRE = function() {
    return this.replace(/(\\])/g, "\\$1");
};

/** parse a JSON String */
String.prototype.parseJSON = function() {
    var text    = this.replace(//g, function(a) { return '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); });
    if (!/^,:{}\s]*$/.test(text.replace(/\\(?:|u{4})/g, '@').replace(/"*"|true|false|null|-?\d+(?:\.\d*)?(?:?\d+)?/g, ']').replace(/(?:^|:|,)(?:\s*\[)+/g, '')))
            throw "invalid JSON string";
    return eval("(" + text + ")");
};

/** replace ${name} with the name property of the args object */
String.prototype.template = function(args) {
    return this.template2("${", "}", args);
};

/** replace prefix XXX suffix with the name property of the args object */
String.prototype.template2 = function(prefix, suffix, args) {
    // /\$\{(+?)\}/g
    var re  = new RegExp(prefix.escapeRE() + "(+?)" + suffix.escapeRE(), "g");
    return this.replace(re, function($0, $1) { 
        var arg = args; 
        return arg !== undefined ? arg : $0;
    });
};

//------------------------------------------------------------------------------
//## Number

/** create an array of number from inclusive to exclusive */
Number.range = function(from, to) {
    var out = ;
    for (var i=from; i<to; i++) out.push(i);
    return out;
};

//======================================================================
//## util/TextUtil.js 

/** text utilities */
var TextUtil = {
    /** 
     * gets an Array of search/replace-pairs (two Strings) and returns 
     * a function taking a String and replacing every search-String with
     * the corresponding replace-string
     */
    recoder: function(pairs) {
        var search  = ;
        var replace = {};
        for (var i=0; i<pairs.length; i++) {
            var pair    = pairs;
            search.push(pair.escapeRE());
            replace] = pair; 
        }
        var regexp  = new RegExp(search.join("|"), "gm");
        return function(s) { 
                return s.replace(regexp, function(dollar0) {  
                        return replace; }); };
    },
    
    /** concatenate all non-empty values in an array with a separator */
    joinPrintable: function(separator, values) {
        var filtered    = ;
        for (var i=0; i<values.length; i++) {
            var value   = values;
            if (value === null || value === "") continue; 
            filtered.push(value);
        }
        return filtered.join(separator ? separator : "");
    },
    
    /** make a function returning its argument */
    idFunc: function() {
        return function(s) {
            return s;
        };
    },
    
    /** make a function returning a constant value */
    constFunc: function(s) {
        return function() {
            return s;
        };
    },
    
    /** make a function returning its argument */
    replaceFunc: function(search, replace) {
        return function(s) {
            return s.replace(search, replace);
        };
    },
    
    /** make a function adding a given prefix */
    prefixFunc: function(separator, prefix) {
        return function(suffix) { 
            return TextUtil.joinPrintable(separator, ); 
        };
    },
    
    /** make a function adding a given suffix */
    suffixFunc: function(separator, suffix) {
        return function(prefix) { 
            return TextUtil.joinPrintable(separator, );
        };
    }//,
};

//======================================================================
//## util/XMLUtil.js 

/** XML utility functions */
var XMLUtil = {
    //------------------------------------------------------------------------------
    //## DOM
    
    /** parses a String into an XMLDocument */
    parseXML: function(text) {
        var doc     = new DOMParser().parseFromString(text, "text/xml");
        var root    = doc.documentElement;
        // root.namespaceURI === "http://www.mozilla.org/newlayout/xml/parsererror.xml"
        if (root.tagName === "parserError"  // ff 2
        || root.tagName === "parsererror")  // ff 3
                throw "XML parser error: " + root.textContent;
        return doc;
    },
    
    /** serialize an XML (e4x) or XMLDocument to a String */
    unparseXML: function(xml) {
        return new XMLSerializer().serializeToString(xml);
    },
    
    //------------------------------------------------------------------------------
    //## E4X
    
    /** parses a String into an e4x XML object */
    parseE4X: function(text) {
        
        return new XML(text.replace(/^<\?xml*>/, ""));
    },
    
    /** serialize an XML (e4x) to a String */
    unparseE4X: function(xml) {
        return xml.toXMLString();
    },
    
    //------------------------------------------------------------------------------
    //## escaping
    
    /** escapes XML metacharacters */
    encode: function(str) { 
        return str.replace(/&/g,    '&amp;')
                    .replace(/</g,  '&lt;')
                    .replace(/>/g,  '&gt;');
    },
    
    /** escapes XML metacharacters including double quotes */
    encodeDQ: function(str) {
        return str.replace(/&/g,    '&amp;')
                    .replace(/</g,  '&lt;')
                    .replace(/>/g,  '&gt;')
                    .replace(/\"/g, '&quot;');
    },
    
    /** escapes XML metacharacters including single quotes */
    encodeSQ: function(str) {
        return str.replace(/&/g,    '&amp;')
                    .replace(/</g,  '&lt;')
                    .replace(/>/g,  '&gt;')
                    .replace(/\'/g, '&apos;');
    },
    
    /** decodes results of encode, encodeDQ and encodeSQ */
    decode: function(code) {
        return code.replace(/&quot/g,   '"')
                    .replace(/&apos/g,  "'")
                    .replace(/&gt;/g,   ">")
                    .replace(/&lt;/g,   "<")
                    .replace(/&amp;/g,  "&");
    }//,
};

//======================================================================
//## util/Loc.js 

/**
 * tries to behave similar to a Location object
 * protocol includes everything before the //
 * host     is the plain hostname
 * port     is a number or null
 * pathname includes the first slash or is null
 * hash     includes the leading # or is null
 * search   includes the leading ? or is null
 */
function Loc(urlStr) {
    var m   = this.parser(urlStr);
    if (!m) throw "cannot parse URL: " + urlStr;
    this.local      = !m;
    this.protocol   = m ? m : null;                           // http:
    this.host       = m ? m : null;                           // de.wikipedia.org
    this.port       = m ? parseInt(m.substring(1)) : null;    // 80
    this.pathname   = m ? m : "";                             // https://wikines.com/de/Test
    this.hash       = m ? m : "";                             // #Industry
    this.search     = m ? m : "";                             // ?action=edit
}
Loc.prototype = {
    /** matches a global or local URL */
    parser: /((.+?)\/\/(+)(:+)?)?(+)?(#*)?(\?.*)?/,

    /** returns the href which is the only usable string representationn of an URL */
    toString: function() {
        return this.hostPart() + this.pathPart();
    },

    /** returns everything before the pathPart */
    hostPart: function() {
        if (this.local) return "";
        return this.protocol + "//" + this.host
            + (this.port ? ":" + this.port  : "");
    },

    /**  returns everything local to the server */
    pathPart: function() {
        return this.pathname + this.hash + this.search;
    },

    /** converts the searchstring into an Array of name/value-pairs */
    args: function() {
        if (!this.search)   return ;
        var out     = ;
        var split   = this.search.substring(1).split("&");
        for (var i=0; i<split.length; i++) {
            var parts   = split.split("=");
            out.push([
                decodeURIComponent(parts), 
                decodeURIComponent(parts)
            ]);
        }
        return out;
    },
    
    /** converts the searchString into a hash. */
    argsMap: function() {
        return Objects.fromPairs(this.args());
        
    }//,
};

//======================================================================
//## util/DOM.js 

/** DOM helper functions */
var DOM = {
    //------------------------------------------------------------------------------
    //## events

    /** executes a function when the DOM is loaded */
    onLoad: function(func) {
        window.addEventListener("DOMContentLoaded", func, false);
    },

    //------------------------------------------------------------------------------
    //## find
    
    /** find an element in document by its id */
    get: function(id) {
        return document.getElementById(id);
    },

    /**
      * find descendants of an ancestor by tagName, className and index 
      * tagName, className and index are optional
      * returns a single element when index exists or an Array of elements if not
      */
    fetch: function(ancestor, tagName, className, index) {
        if (ancestor && ancestor.constructor === String) {
            ancestor    = document.getElementById(ancestor);
        }
        if (ancestor === null)  return null;
        var elements    = ancestor.getElementsByTagName(tagName ? tagName : "*");
        if (className) {
            var tmp = ;
            for (var i=0; i<elements.length; i++) {
                if (this.hasClass(elements, className)) {
                    tmp.push(elements);
                }
            }
            elements    = tmp;
        }
        if (typeof index === "undefined")   return elements;
        if (index >= elements.length)       return null;
        return elements;
    },

    /** find the next element from el which has a given nodeName or is non-text */
    nextElement: function(el, nodeName) {
        if (nodeName)   nodeName    = nodeName.toUpperCase();
        for (;;) {
            el  = el.nextSibling;   if (!el)    return null;
            if (nodeName)   { if (el.nodeName.toUpperCase() === nodeName)   return el; }
            else            { if (el.nodeName.toUpperCase() !== "#TEXT")    return el; }
        }
    },

    /** find the previous element from el which has a given nodeName or is non-text */
    previousElement: function(el, nodeName) {
        if (nodeName)   nodeName    = nodeName.toUpperCase();
        for (;;) {
            el  = el.previousSibling;   if (!el)    return null;
            if (nodeName)   { if (el.nodeName.toUpperCase() === nodeName)   return el; }
            else            { if (el.nodeName.toUpperCase() !== "#TEXT")    return el; }
        }
    },

    /** whether an ancestor contains an element */
    contains: function(ancestor, element) {
        for (;;) {
            if (element === ancestor)   return true;    
            if (element === null)       return false;
            element = element.parentNode;
        }
    },
    
    //------------------------------------------------------------------------------
    //## add

    /** inserts text, a node or an Array of these before a target node */
    pasteBefore: function(target, additum) {
        if (additum.constructor !== Array)  additum = ;
        var parent  = target.parentNode;
        for (var i=0; i<additum.length; i++) {
            var node    = additum;
            if (node.constructor === String)    node    = document.createTextNode(node);
            parent.insertBefore(node, target);
        }
    },

    /** inserts text, a node or an Array of these after a target node */
    pasteAfter: function(target, additum) {
        if (target.nextSibling) this.pasteBefore(target.nextSibling, additum);
        else                    this.pasteEnd(target.parentNode, additum);
    },

    /** insert text, a node or an Array of these at the start of a target node */
    pasteBegin: function(parent, additum) {
        if (parent.firstChild)  this.pasteBefore(parent.firstChild, additum);
        else                    this.pasteEnd(parent, additum);
    },

    /** insert text, a node or an Array of these at the end of a target node */
    pasteEnd: function(parent, additum) {
        if (additum.constructor !== Array)  additum = ;
        for (var i=0; i<additum.length; i++) {
            var node    = additum;
            if (node.constructor === String)    node    = document.createTextNode(node);
            parent.appendChild(node);
        }
    },
    
    //------------------------------------------------------------------------------
    //## remove

    /** remove a node from its parent node */
    removeNode: function(node) {
        node.parentNode.removeChild(node);
    },

    /** removes all children of a node */
    removeChildren: function(node) {
        //while (node.lastChild)    node.removeChild(node.lastChild);
        node.innerHTML  = "";
    },
    
    //------------------------------------------------------------------------------
    //## replace
    
    /** replace a node with another one */
    replaceNode: function(node, replacement) {
        node.parentNode.replaceChild(replacement, node); 
    },

    //------------------------------------------------------------------------------
    //## css classes

    /** creates a RegExp matching a className */
    classNameRE: function(className) {
        return new RegExp("(^|\\s+)" + className.escapeRE() + "(\\s+|$)");
    },

    /** returns an Array of the classes of an element */
    getClasses: function(element) {
        return element.className.split(/\s+/);
    },

    /** returns whether an element has a class */
    hasClass: function(element, className) {
        if (!element.className) return false;
        var re  = this.classNameRE(className);
        return re.test(element.className);
        // return (" " + element.className + " ").indexOf(" " + className + " ") !== -1;
    },

    /** adds a class to an element */
    addClass: function(element, className) {
        if (this.hasClass(element, className))  return;
        var old = element.className ? element.className : "";
        element.className = (old + " " + className).trim();
    },

    /** removes a class to an element */
    removeClass: function(element, className) {
        var re  = this.classNameRE(className);
        var old = element.className ? element.className : "";
        element.className = old.replace(re, "");
    },

    /** replaces a class in an element with another */
    replaceClass: function(element, oldClassName, newClassName) {
        this.removeClass(element, oldClassName);
        this.addClass(element, newClassName);
    },
    
    /** sets or unsets a class on an element */
    updateClass: function(element, className, active) {
        var has = this.hasClass(element, className);
        if (has === active) return;
        if (active) this.addClass(element, className);
        else        this.removeClass(element, className);
    },

    //------------------------------------------------------------------------------
    //## position

    /** mouse position in document base coordinates */
    mousePos: function(event) {
        return {
            x: window.pageXOffset + event.clientX,
            y: window.pageYOffset + event.clientY
        };
    },
    
    /** minimum visible position in document base coordinates */
    minPos: function() {
        return {
            x: window.scrollX,
            y: window.scrollY
        };
    },
    
    /** maximum visible position in document base coordinates */
    maxPos: function() {
        return {
            x: window.scrollX + window.innerWidth,
            y: window.scrollY + window.innerHeight
        };
    },
    
    /** position of an element in document base coordinates */
    elementPos: function(element) {
        var parent  = this.elementParentPos(element);
        return {
            x: element.offsetLeft   + parent.x,
            y: element.offsetTop    + parent.y
        };
    },

    /** size of an element */
    elementSize: function(element) {
        return {
            x: element.offsetWidth,
            y: element.offsetHeight
        };
    },

    /** document base coordinates for an elements parent */
    elementParentPos: function(element) {
        // TODO inline in elementPos?
        var pos = { x: 0, y: 0 };
        for (;;) {
            var mode = window.getComputedStyle(element, null).position;
            if (mode === "fixed") {
                pos.x   += window.pageXOffset;
                pos.y   += window.pageYOffset;
                return pos;
            }
            var parent  = element.offsetParent;
            if (!parent)    return pos;
            pos.x   += parent.offsetLeft;
            pos.y   += parent.offsetTop;
            // TODO add scrollTop and scrollLeft here?
            element = parent;
        }
    },
    
    /** moves an element to document base coordinates */
    moveElement: function(element, pos) {
        var container   = this.elementParentPos(element);
        element.style.left  = (pos.x - container.x) + "px";
        element.style.top   = (pos.y - container.y) + "px"; 
    }//,
};

//======================================================================
//## util/Cookie.js 

/** helper functions for cookies */
var Cookie = {
    TTL_DEFAULT:    1*31*24*60*60*1000, // in a month
    TTL_DELETE:       -3*24*60*60*1000, // 3 days before
    
    /** get a named cookie or returns null */
    get: function(key) {
        var point   = new RegExp("\\b" + encodeURIComponent(key).escapeRE() + "=");
        var s       = document.cookie.split(point);
        if (!s) return null;
        s   = s.split(";").replace(/ *$/, "");
        return decodeURIComponent(s);
    },

    /** set a named cookie */
    set: function(key, value, expires) {
        if (!expires)   expires = this.timeout(this.TTL_DEFAULT);
        document.cookie = encodeURIComponent(key) + "=" + encodeURIComponent(value) +
                        "; expires=" + expires.toGMTString() +
                        "; path=/";
    },

    /** delete a named cookie */
    del: function(key) {
        this.set(key, "", 
                this.timeout(this.TTL_DELETE));
    },

    /** calculate a date a given number of millis in the future */
    timeout: function(offset) {
        var expires     = new Date();
        expires.setTime(expires.getTime() + offset);
        return expires;
    }//,
};

//======================================================================
//## util/Ajax.js 

/** ajax helper */
var Ajax = {
    /** 
     * create and use an XMLHttpRequest with named parameters 
     *
     * data
     *      method      optional string, defaults to GET
     *      url         mandatory string, may contains parameters
     *      urlParams   optional map or Array of pairs, can be used together with params in url
     *      body        optional string
     *      bodyParams  optional map or Array of pairs, overwrites body
     *      charset     optional string for bodyParams
     *      headers     optional map
     *      timeout     optional number of milliseconds
     *
     * callbacks, all get the client as first parameter
     *      exceptionFunc       called when the client throws an exception
     *      completeFunc        called after the more specific functions
     *      noSuccessFunc       called in all cases when no success
     *
     *      successFunc         called for 200..300, gets the responseText
     *      intermediateFunc    called for 300..400
     *      failureFunc         called for 400..500
     *      errorFunc           called for 500..600
     */
    call: function(args) {
        if (!args.url)  throw "url argument missing";
        
        // create client
        var client  = new XMLHttpRequest();
        client.args = args;
        client.debug = function() {
            return client.status + " " + client.statusText + "\n"
                    + client.getAllResponseHeaders() + "\n\n"
                    + client.responseText;
        };
        
        // init client
        var method  = args.method || "GET";
        var url     = args.url;
        if (args.urlParams) {
            url += url.indexOf("?") === -1 ? "?" : "&";
            url += this.encodeUrlArgs(args.urlParams);
        }
        client.open(method, url, true);
        
        // state callback
        client.onreadystatechange = function() {
            if (client.readyState !== 4)    return;
            if (client.timer)   clearTimeout(client.timer);
            
            var status  = -1;
            try { status    = client.status; }
            catch (e) {
                if (args.exceptionFunc)     args.exceptionFunc(client, e);
                if (args.noSuccessFunc)     args.noSuccessFunc(client, e);
                return;
            }
                
            if (status >= 200 && status < 300) {
                if (args.successFunc)       args.successFunc(client, client.responseText);
            }
            else if (status >= 300 && status < 400) {
                // TODO location-header?
                if (args.intermediateFunc)  args.intermediateFunc(client);
            }
            else if (status >= 400 && status < 500) {
                if (args.failureFunc)       args.failureFunc(client);
            }
            else if (status >= 500 && status < 600) {
                if (args.errorFunc)         args.errorFunc(client);
            }
            
            if (args.completeFunc)  args.completeFunc(client);
            if (status < 200 || status >= 300) {
                if (args.noSuccessFunc)     args.noSuccessFunc(client);
            }
        };
        
        
        // init headers
        if (args.bodyParams) {
            var contentType = "application/x-www-form-urlencoded";
            if (args.charset)   contentType += "; charset=" + args.charset;
            client.setRequestHeader("Content-Type", contentType);
        }
        if (args.headers) {
            for (var key in args.headers) {
                client.setRequestHeader(key, args.headers);
            }
        }
        
        // init body
        var body;
        if (args.bodyParams) {
            body    = this.encodeFormArgs(args.bodyParams);
        }
        else {
            body    = args.body || null;
        }
        
        // send
        if (args.timeout) {
            client.timer    = setTimeout(client.abort.bind(client), args.timeout);
        }
        client.send(body);
        
        
    },
    
    //------------------------------------------------------------------------------
    //## private
    
    /** 
     * url-encode arguments
     * args may be an Array of Pair of String or a Map from String to String 
     */
    encodeUrlArgs: function(args) {
        if (args.constructor !== Array) args    = this.hashToPairs(args);
        return this.encodeArgPairs(args, encodeURIComponent);
    },
    
    /**
     * encode arguments into application/x-www-form-urlencoded 
     * args may be an Array of Pair of String or a Map from String to String 
     */
    encodeFormArgs: function(args) {
        if (args.constructor !== Array) args    = this.hashToPairs(args);
        return this.encodeArgPairs(args, this.encodeFormValue);
    },
    
    /** compile an Array of Pairs of Strings into the &name=value format */
    encodeArgPairs: function(args, encodeFunc) {
        var out = "";
        for (var i=0; i<args.length; i++) {                       
            var pair    = args;
            if (pair.constructor !== Array) throw "expected a Pair: " + pair;
            if (pair.length !== 2)          throw "expected a Pair: " + pair;
            if (pair === null)   continue;
            out += "&"  + encodeFunc(pair)
                +  "="  + encodeFunc(pair);
        }
        return out && out.substring(1);
    },
    
    /** encode a single form-value. this is a variation on url-encoding */
    encodeFormValue: function(value) {
        // use windows-linefeeds
        value   = value.replace(/\r\n|\n|\r/g, "\r\n");
        // escape funny characters
        value   = encodeURIComponent(value);
        // space is encoded as a plus sign instead of "%20"
        value   = value.replace(/(^|)(%%)*%20/g, "$1$2+");
        return value;
    },
    
    /** 
     * converts a hash into an Array of Pairs (2-element Arrays). 
     * null values generate no Pair, 
     * array values generate multiple Pairs, 
     * other values are toString()ed 
     */
    hashToPairs: function(map) {
        var out = ;
        for (var key in map) {
            var value   = map;
            if (value === null) continue;
            if (value.constructor === Array) {
                for (var i=0; i<value.length;i++) {
                    var subValue    = value;
                    if (subValue === null)  continue;
                    out.push();
                }
                continue;
            }
            out.push();
        }
        return out;
    }//,
};

//======================================================================
//## util/Form.js 

/** HTMLFormElement helper functions */
var Form = {
    //------------------------------------------------------------------------------
    ///## finder
    
    /** finds a HTMLForm or returns null */
    find: function(ancestor, nameOrIdOrIndex) {
        var forms   = ancestor.getElementsByTagName("form");
        if (typeof nameOrIdOrIndex === "number") {
            if (nameOrIdOrIndex >= 0
            && nameOrIdOrIndex < forms.length)  return forms;
            else                                return null;
        }
        for (var i=0; i<forms.length; i++) {
            var form    = forms;
            if (this.elementNameOrId(form) === nameOrIdOrIndex) return form;
        }
        return null;
    },
    
    /** returns the name or id of an element or null */
    elementNameOrId: function(element) {
        return  element.name    ? element.name
            :   element.id      ? element.id
            :   null;
    },
    
    //------------------------------------------------------------------------------
    //## serializer
    
    /**
     * parses HTMLFormElement and its HTML*Element children 
     * into an Array of name/value-pairs (2-element Arrays).
     * these pairs can be used as bodyArgs parameter for Ajax.call.
     *
     * returns an Array of Pairs, optionally with one of
     * the button/image/submit-elements activated
     */
    serialize: function(form, buttonName) {
        var out = ;
        for (var i=0; i<form.elements.length; i++) {
            var element = form.elements;
            
            if (!element.name)      continue;
            if (element.disabled)   continue;
        
            var handlingButton = element.type === "submit" 
                                || element.type === "image" 
                                || element.type === "button";
            if (handlingButton 
            && element.name !== buttonName) continue;
            
            var pairs   = this.elementPairs(element);
            out = out.concat(pairs);
        }
        return out;
    },
    
    /** 
     * returns an Array of Pairs for a single input element.
     * in most cases, it contains zero or one Pair. 
     * more than one are possible for select-multiple.
     */
    elementPairs: function(element) {
        var name    = element.name;
        var type    = element.type;
        var value   = element.value;
        
        if (type === "reset") {
            return ;
        }
        else if (type === "submit") {
            if (value)  return  ];
            else        return  ];
        }
        else if (type === "button" || type === "image") {
            if (value)  return  ];
            else        return  ];
        }
        else if (type === "checkbox" || type === "radio") {
                 if (!element.checked)  return ;
            else if (value !== null)    return  ];
            else                        return  ];    
        }
        else if (type === "select-one") {
            if (element.selectedIndex !== -1)   return  ];
            else                                return ;
        }
        else if (type === "select-multiple") {
            var pairs   = ;
            for (var i=0; i<element.options.length; i++) {
                var opt = element.options;
                if (!opt.selected)  continue;
                pairs.push();
            }
            return pairs;
        }
        else if (type === "text" || type === "password" || type === "hidden" || type === "textarea") {
            if (value)  return  ];
            else        return  ];
        }
        else if (type === "file") {
            // NOTE: can't do anything here :(
            return ;
        }
        else {
            throw "field: " + name + " has the unknown type: " + type;
        }
    }//,
};

//======================================================================
//## lib/core/Actions.js 

/** 
 * ajax functions for MediaWiki
 * uses wgScript, wgScriptPath and Titles.specialPage
 */
var Actions = {
    /** 
     * example feedback implementation, implement this interface
     * if you want to get notified about an Actions progress
     */
    NoFeedback: {
        job:        function(s) {},
        work:       function(s) {},
        success:    function(s) {},
        failure:    function(s) {}//,
    },

    //------------------------------------------------------------------------------
    //## change page content
    
    /** replace the text of a page with a replaceFunc. the replaceFunc can return null to abort. */
    replaceText: function(feedback, title, replaceFunc, summary, minorEdit, allowCreate, doneFunc) {
        this.editPage(feedback, title, null, null, summary, minorEdit, allowCreate, replaceFunc, doneFunc);
    },

    /** add text to the end of a spage, the separator is optional */
    appendText: function(feedback, title, text, summary, separator, allowCreate, doneFunc) {
        var changeFunc = TextUtil.suffixFunc(separator, text);
        this.editPage(feedback, title, null, null, summary, false, allowCreate, changeFunc, doneFunc);
    },

    /** add text to the start of a page, the separator is optional */
    prependText: function(feedback, title, text, summary, separator, allowCreate, doneFunc) {
        // could use section=0 if there wasn't the separator
        var changeFunc = TextUtil.prefixFunc(separator, text);
        this.editPage(feedback, title, null, null, summary, false, allowCreate, changeFunc, doneFunc);
    },

    /** restores a page to an older version */
    restoreVersion: function(feedback, title, oldid, summary, doneFunc) {
        var changeFunc  = TextUtil.idFunc();
        this.editPage(feedback, title, oldid, null, summary, false, false, changeFunc, doneFunc);
    },
    
    /**
     * edits a page's text
     * except feedback and title, all parameters can be null
     */
    editPage: function(feedback, title, oldid, section, summary, minor, allowCreate, textFunc, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("editing page: " + title + " with oldid: " + oldid + " and section: " + section);
        var args = {
            title:      title,
            oldid:      oldid,          
            section:    section,
            action:     "edit"
        };
        var self    = this;
        function change(form, doc) {
            if (!allowCreate && doc.getElementById("newarticletext"))   return false;
            if (summary !== null)   form.elements.value        = summary;
            if (minor !== null)     form.elements.checked    = minor;
            var text    = form.elements.value;
            if (textFunc) {
                text    = text.replace(/^+$/, "");
                text    = textFunc(text);
                if (text === null) { feedback.failure("aborted"); return false; }
            }
            form.elements.value       = text
            return true;
        }
        var afterEdit   = this.afterEditFunc(feedback, doneFunc);
        this.action(feedback, args, "editform", change, 200, afterEdit);
    },
    
    /** undoes an edit */
    undoVersion: function(feedback, title, undo, undoafter, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("undoing page: " + title + " with undo: " + undo + " and undoafter: " + undoafter);
        var args = {
            title:      title,
            undo:       undo,
            undoafter:  undoafter,
            action:     "edit"
        };
        function change(form, doc) {
            return true;
        }
        var afterEdit   = this.afterEditFunc(feedback, doneFunc);
        this.action(feedback, args, "editform", change, 200, afterEdit);
    },
    
    /** 
     * finds the newest edits of a user on a page 
     * the foundFunc is called with title, user, previousUser, revId and timestamp
     */
    newEdits: function(feedback, title, user, foundFunc) {
        feedback    = feedback || this.NoFeedback;
        var self    = this;
        
        function phase1() {
            feedback.work("fetching revision history");
            var apiArgs = {
                action: "query",
                prop:   "revisions",
                titles: title,
                rvprop: "ids|timestamp|flags|comment|user", 
                rvlimit: 50//,
            };
            self.apiCall(feedback, apiArgs, phase2);
        }
        function phase2(json) {
            feedback.work("parsing revision history");
            
            /** returns the first element of a map */
            function firstElement(map) {
                for (key in map) { return map; }
                return null;
            }
            var page    = firstElement(json.query.pages);
            if (page == null) {
                feedback.failure("no suitable revision found");
                return;
            }
            
            var rev = (function() {
                var revs    = page.revisions;
                for (var i=0; i<revs.length; i++) {
                    var rev = revs;
                    rev.index   = i;
                    if (rev.user !== user)  return rev;
                }
                return null;    // no version found;
            })();
                    
            if (rev === null) {
                feedback.failure("no suitable revision found");
                return;
            }
            if (rev.index === 0)    {
                feedback.failure("found conflicting revision by user " + rev.user);
                return;
            }
            
            feedback.success("found revision " + rev.revid);
            foundFunc(title, user, rev.user, rev.revid, rev.timestamp);
        }
        
        phase1();
    },

    //------------------------------------------------------------------------------
    //## change page state

    /** watch or unwatch a page. the doneFunc is optional */
    watchedPage: function(feedback, title, watch, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        var action  = watch ? "watch" : "unwatch";
        feedback.job(action + " page: " + title);
        var actionArgs  = {
            title:  title,
            action: action//,
        };
        feedback.work("GET " + mw.config.get('wgScript') + " with " + this.debugArgsString(actionArgs));
        function done(source) {
            if (source.status !== 200) {
                // source.args.method, source.args.url
                feedback.failure(source.status + " " + source.statusText);
                return;
            }
            feedback.success("done");
            if (doneFunc)   doneFunc();
        }
        Ajax.call({
            method:         "GET",
            url:            mw.config.get('wgScript'),
            urlParams:      actionArgs,
            successFunc:    done//,
        });
    },

    /** move a page */
    movePage: function(feedback, oldTitle, newTitle, reason, moveTalk, moveSub, watch, leaveRedirect, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("move page: " + oldTitle + " to: " + newTitle);
        var args = {
            title:  this.specialTitle("MovePage"),
            target: oldTitle    // url-encoded, mandatory
        };
        function change(form, doc) {
            form.elements.value           = oldTitle;
            form.elements.value           = newTitle;
            form.elements.value             = reason;
            if (form.elements)
            form.elements.checked         = moveTalk;
            if (form.elements)
            form.elements.checked     = moveSub;
            if (form.elements)
            form.elements.checked    = leaveRedirect;
            form.elements.value              = watch;
            // TODO wpConfirm
            return true;
        }
        this.action(feedback, args, "movepage", change, 200, doneFunc);
    },
    
    /** rollback an edit, the summary may be null */
    rollbackEdit: function(feedback, title, from, token, summary, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("rolling back page: " + title + " from: " + from);
        var actionArgs = {
            title:      title,
            from:       from,
            token:      token,
            summary:    summary,
            action:     "rollback"//,
        };
        feedback.work("GET " + mw.config.get('wgScript') + " with " + this.debugArgsString(actionArgs));
        function done(source) {
            if (source.status !== 200) {
                // source.args.method, source.args.url
                feedback.failure(source.status + " " + source.statusText);
                return;
            }
            feedback.success("done");
            if (doneFunc)   doneFunc();
        }
        Ajax.call({
            method:         "GET",
            url:            mw.config.get('wgScript'),
            urlParams:      actionArgs,
            successFunc:    done//,
        });
    },

    /** delete a page. if the reason is null, the original reason text is deleted */
    deletePage: function(feedback, title, reason, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("delete page: " + title);
        var args = {
            title:  title,
            action: "delete"
        };
        function change(form, doc) {
            if (reason !== null) {
                reason  =  TextUtil.joinPrintable(" - ", [ 
                                reason,
                                form.elements.value ]);
            }
            else {
                reason  = "";
            }
            form.elements.value = reason;
            return true;
        }
        this.action(feedback, args, "deleteconfirm", change, 200, doneFunc);
    },
    
    /** delete a file. if the reason is null, the original reason text is deleted */
    deleteFile: function(feedback, title, reason, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("delete file: " + title);
        var args = {
            title:  title,
            action: "delete"
        };
        function change(form, doc) {
            if (reason !== null)     {
                reason  = TextUtil.joinPrintable(" - ", [ 
                                reason,
                                form.elements.value ]);
            }
            else {
                reason  = "";
            }
            form.elements.value = reason;
            // mw-filedelete-submit
            return true;
        }
        this.action(feedback, args, 0, change, 200, doneFunc);
    },

    /**
     * change a page's protection state
     * allowed values for the levels are "", "autoconfirmed" and "sysop"
     * cascade should be false in most cases
     * expiry may be empty for indefinite, "indefinite", 
     * or a number followed by a space and 
     * "years", "months", "days", "hours" or "minutes"
     */
    protectPage: function(feedback, title, 
            levelEdit, expiryEdit, 
            levelMove, expiryMove,
            levelCreate, expiryCreate, 
            reason, cascade, watch, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("protect page: " + title);
        var args    = {
            title:  title,
            action: "protect"
        };
        function change(form, doc) {
            // for existing pages
            if (form.elements)
            form.elements.value     = levelEdit;    // plus mwProtectExpirySelection-edit named wpProtectExpirySelection-edit
            if (form.elements)
            form.elements.value   = expiryEdit;   // named mwProtect-expiry-edit
            
            // for existing pages
            if (form.elements)
            form.elements.value     = levelMove;    // plus mwProtectExpirySelection-move named wpProtectExpirySelection-move
            if(form.elements)
            form.elements.value   = expiryMove;   // named mwProtect-expiry-move
            
            // for deleted pages
            if (form.elements)
            form.elements.value   = levelCreate;  // plus mwProtectExpirySelection-create named wpProtectExpirySelection-create
            if (form.elements)
            form.elements.value = expiryMove;   // named mwProtect-expiry-create
        
         
            // for both deleted and existing pages
            form.elements.checked  = cascade;
            form.elements.value         = reason;   // plus wpProtectReasonSelection    
            form.elements.value           = watch;
             
            
            return true;
        }
        // this form does not have a name
        this.action(feedback, args, 0, change, 200, doneFunc);
    },

    //------------------------------------------------------------------------------
    //## change other data

    /** 
     * block a user. 
     * anonOnly, createAccounts, enableAutoblock and allowUserTalk default to true
     * expiry may be "indefinite", 
     * or a number followed by a space and 
     * "years", "months", "days", "hours" or "minutes"
     */
    blockUser: function(feedback, user, expiry, reason, anonOnly, createAccount, enableAutoblock, emailBan, allowUserTalk, watchUser, allowChange, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("block user: " + user + " for: " + expiry);
        var args = {
            title:  this.specialTitle("BlockIP"),
            ip:     user    // url-encoded, optional
        };
        function change(form, doc) {
            if (!allowChange && form.elements) return false;
            form.elements.value       = user;
            form.elements.value        = reason;
            form.elements.checked         = anonOnly;
            form.elements.checked    = createAccount;
            form.elements.checked  = enableAutoblock;
            form.elements.checked         = emailBan;
            form.elements.checked    = allowUserTalk;
            form.elements.checked        = watchUser;
            form.elements.value         = expiry;
            
            return true;
        }
        this.action(feedback, args, "blockip", change,  200, doneFunc);
    },

    /** send an email to a user. */
    sendEmail: function(feedback, user, subject, body, ccSelf, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        feedback.job("sending email to user: " + user + " with subject: " + subject);
        var args = {
            title:  this.specialTitle("EmailUser"),
            target: user
        };
        function change(form, doc) {
            form.elements.value    = subject;
            form.elements.value       = body;
            form.elements.value       = ccSelf;
            return true;
        }
        this.action(feedback, args, "emailuser", change,  200, doneFunc);
    },
    
    //------------------------------------------------------------------------------
    //## private
    
    /** returns a doneFunc displaying an error if an edit was not successful or calls the (optional) doneFunc */
    afterEditFunc: function(feedback, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        return function(text) {
            var doc;
            try { 
                doc = XMLUtil.parseXML(text);
            }
            catch (e) {
                feedback.failure("cannot parse XML: " + e);
                return;
            }
            if (doc.getElementById('wikiPreview')) {
                feedback.failure("cannot save, preview detected");
                return;
            }
            var form    = Form.find(doc, "editform");
            if (form) {
                feedback.failure("cannot save, editform detected");
                return;
            }
            if (doneFunc)   doneFunc(text);
        };
    },

    /**
     * get a form, change it, post it.
     * the changeFunc gets the form as its first, the complete document as its second parameter
     * and modifies this form in-place. it may return false to abort.
     * the doneFunc is called  after modification with the document text and may be left out
     */
    action: function(feedback, actionArgs, formName, changeFunc, expectedPostStatus, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        var self    = this;
        function phase1() {
            // get the form
            feedback.work("GET " + mw.config.get('wgScript') + " with " + self.debugArgsString(actionArgs));
            Ajax.call({
                method:         "GET",
                url:            mw.config.get('wgScript'),
                urlParams:      actionArgs,
                successFunc:    phase2,
                noSuccessFunc:  failure//,
            });
        }
        function phase2(source) {
            // check status
            var expectedGetStatus   = 200;
            if (expectedGetStatus && source.status !== expectedGetStatus) {
                feedback.failure(source.status + " " + source.statusText);
                return;
            }

            // get document
            var doc;
            try {
                doc = XMLUtil.parseXML(source.responseText);
            }
            catch (e) {
                feedback.failure("cannot parse XML: " + e);
                return;
            }
            
            // get form
            var form    = Form.find(doc, formName);
            if (form === null) { 
                feedback.failure("missing form: " + formName); 
                return; 
            }
            
            // modify form
            var ok;
            try {
                ok  = changeFunc(form, doc);
            }
            catch(e) {
                feedback.failure("cannot change form: " + e);
                return;
            }
            if (!ok) {
                feedback.failure("aborted");
                return;
            }
            
            // post the form
            var url     = form.action;
            var data    = Form.serialize(form);
            feedback.work("POST " + url);
            Ajax.call({
                method:         "POST",
                url:            url,
                bodyParams:     data,
                successFunc:    phase3,
                noSuccessFunc:  failure//,
            });
        }
        function phase3(source) {
            // check status
            if (expectedPostStatus && source.status !== expectedPostStatus) {
                feedback.failure(source.status + " " + source.statusText);
                return;
            }
            
            // done
            feedback.success("done");
            if (doneFunc)   doneFunc(source.responseText);
        }
        function failure(source) {
            feedback.failure(source.status + ": " + self.debugArgsString(source.args));
        }
        phase1();
    },
    
    /** call the api, calls doneFunc with the JSON result (and the response text) if successful */
    apiCall: function(feedback, args, doneFunc) {
        feedback    = feedback || this.NoFeedback;
        //var   self    = this;
        function phase1() {
            var apiPath     = mw.config.get('wgScriptPath') + "/api.php";
            var bodyParams  = {
                format: "json"//,
            };
            Objects.copySlots(args, bodyParams);
            feedback.work("POST " + apiPath);
            Ajax.call({
                url:            apiPath,
                method:         "POST",
                bodyParams:     bodyParams,
                successFunc:    phase2,
                noSuccessFunc:  failure//,
            });
        }
        function phase2(source) {
            var text    = source.responseText;
            var json;
            try {
                json    = text.parseJSON();
            }
            catch (e) {
                feedback.failure("cannot parse JSON: " + e);
                return;
            }
            feedback.success("done");
            if (doneFunc)   doneFunc(json, text);
        }
        function failure(source) {
            feedback.failure("api status: " + source.status);
        }
        phase1();
    },
    
    /** bring a map into a human readable form */
    debugArgsString: function(args) {
        var out = "";
        for (key in args) {
            var arg = args;
            if (arg !== null && arg.constructor === Function) continue;
            out += ", " + key + ": " + arg;
        }
        return out.substring(2); 
    },
    
    /** SpecialPage access, uses Titles.specialPage if Titles exists */
    specialTitle: function(specialName) {
        // HACK for standalone operation
        if (!window.Titles)  return "Special:" + specialName;
        return Titles.specialPage(specialName);
    }//,
};

/* </nowiki></pre> */