Benutzer:Paulis/monobook/admin.js

aus Wikisource, der freien Quellensammlung

/* w:Benutzer:D/monobook ::: version für admins ::: w:Benutzer:D/monobook/admin.js */

/*

 */

//======================================================================
//## util/Object.js 

var jsutil  = jsutil || {};

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

/** Object helper functions */
jsutil.Object = {
    /** return a type-indicating string */
    type: function(obj) {
        return  obj === null    ? "null"        :
                obj == null     ? "undefined"   :
                Object.prototype.toString.call(obj).match(/(\w+)\]/)[1];
    },
    
    
/** returns all keys as an Array */
    keys: function(obj) {
        var out = [];
        for (var key in obj)
                if (obj.hasOwnProperty(key))
                        out.push(key);
        return out;
    },
    
    /** returns the value behind every key */
    values: function(obj) {
        var out = [];
        for (var key in obj)
                if (obj.hasOwnProperty(key))
                        out.push(obj[key]);
        return out;
    },
    
    /** copies an Object's properties into an new Object */
    clone: function(obj) {
        var out = {};
        for (var key in obj)
                if (obj.hasOwnProperty(key))    
                        out[key] = obj[key];
        return out;
    },
    
    /** 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([key, obj[key]]);
        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[i];
            out[pair[0]]    = pair[1];
        }
        return out;
    }//,
};

//======================================================================
//## util/Function.js 

var jsutil  = jsutil || {};

/** can be used to copy a function's arguments into a real Array */
jsutil.Function = {
    identity:   function(x) { return x; },
    constant:   function(c) { return function(v) { return c; }; }
};

//======================================================================
//## util/Number.js 

var jsutil  = jsutil || {};

/** can be used to copy a function's arguments into a real Array */
jsutil.Number   = {
    range:  function(from, to) {
        var out = [];
        for (var i=from; i<to; i++) out.push(i);
        return out;
    }
};

//======================================================================
//## util/Text.js 

var jsutil  = jsutil || {};

/** text utilities */
jsutil.Text = {
    /** 
     * 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[i];
            search.push(pair[0].escapeRE());
            replace[pair[0]] = pair[1]; 
        }
        var regexp  = new RegExp(search.join("|"), "gm");
        return function(s) { 
                return s.replace(regexp, function(dollar0) {  
                        return replace[dollar0]; }); };
    },
    
    /** 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[i];
            if (value === null || value === "") continue; 
            filtered.push(value);
        }
        return filtered.join(separator ? separator : "");
    },
    
    /** 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 jsutil.Text.joinPrintable(separator, [ prefix, suffix ]); 
        };
    },
    
    /** make a function adding a given suffix */
    suffixFunc: function(separator, suffix) {
        return function(prefix) { 
            return jsutil.Text.joinPrintable(separator, [ prefix, suffix ]);
        };
    }//,
};

//======================================================================
//## util/XML.js 

var jsutil  = jsutil || {};

/** XML utility functions */
jsutil.XML = {
    //------------------------------------------------------------------------------
    //## DOM
    
    /** parses a String into an XMLDocument */
    parseXML: function(text) {
        // TODO text/html does work on firefox, but not on webkit
        var doc     = new DOMParser().parseFromString(text, "text/html");
        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 new Error("XML parser error: " + root.textContent);
        return doc;
    },
    
/** serialize an XML (e4x) or XMLDocument to a String */
    unparseXML: function(xml) {
        return new XMLSerializer().serializeToString(xml);
    },
    
    //------------------------------------------------------------------------------
    //## escaping
    
    /** escapes XML metacharacters */
    encode: function(str) { 
        return str.replace(/&/g,    '&')
                    .replace(/</g,  '<')
                    .replace(/>/g,  '>');
    },
    
    /** escapes XML metacharacters including double quotes */
    encodeDQ: function(str) {
        return str.replace(/&/g,    '&')
                    .replace(/</g,  '<')
                    .replace(/>/g,  '>')
                    .replace(/\"/g, '"');
    },
    
    /** escapes XML metacharacters including single quotes */
    encodeSQ: function(str) {
        return str.replace(/&/g,    '&')
                    .replace(/</g,  '<')
                    .replace(/>/g,  '>')
                    .replace(/\'/g, ''');
    },
    
    /** decodes results of encode, encodeDQ and encodeSQ */
    decode: function(code) {
        return code.replace(/&quot/g,   '"')
                    .replace(/&apos/g,  "'")
                    .replace(/>/g,   ">")
                    .replace(/</g,   "<")
                    .replace(/&/g,  "&");
    }//,
};

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

// @depends jsutil/Object

var jsutil  = jsutil || {};

/**
 * 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
 */
jsutil.Loc  = function(urlStr) {
    var m   = this.parser.exec(urlStr);
    if (!m) throw new Error("cannot parse URL: " + urlStr);
    this.local      = !m[1];
    this.protocol   = m[2] ? m[2] : null;                           // http:
    this.host       = m[3] ? m[3] : null;                           // de.wikipedia.org
    this.port       = m[4] ? parseInt(m[4].substring(1)) : null;    // 80
    this.pathname   = m[5] ? m[5] : "";                             // /wiki/Test
    this.hash       = m[6] ? m[6] : "";                             // #Industry
    this.search     = m[7] ? m[7] : "";                             // ?action=edit
};
jsutil.Loc.prototype = {
    /** matches a global or local URL */
    parser: /((.+?)\/\/([^:\/]+)(:[0-9]+)?)?([^#?]+)?(#[^?]*)?(\?.*)?/,

    /** 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[i].split("=");
            out.push([
                decodeURIComponent(parts[0]), 
                decodeURIComponent(parts[1])
            ]);
        }
        return out;
    },
    
    /** converts the searchString into a hash. */
    argsMap: function() {
        var pairs   = this.args();
        var out     = {};
        for (var i=0; i<pairs.length; i++) {
            var pair    = pairs[i];
            var key     = pair[0];
            var value   = pair[1];
            // if (out.hasOwnProperty(key)) throw new Error("duplicate argument: " + key);
            out[key]    = value;
        }
        return out;
    }//,
};

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

var jsutil  = jsutil || {};

/** DOM helper functions */
jsutil.DOM = {
    //------------------------------------------------------------------------------
    //## events

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

    //------------------------------------------------------------------------------
    //## find
    
    /** checks if obj is a DOM node */
    isNode: function(obj) {
        return !!(obj && obj.nodeType);
    },
    
    /** 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[i], className)) {
                    tmp.push(elements[i]);
                }
            }
            elements    = tmp;
        }
        if (typeof index === "undefined")   return elements;
        if (index >= elements.length)       return null;
        return elements[index];
    },

    /** 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;
        }
    },
    
    //------------------------------------------------------------------------------
    //## single insertions
    
    // BETTER implement something like an insertion point?
    
    /** insert a node as the first child of the parent */
    insertBegin: function(parent, node) {
        parent.insertBefore(node, parent.firstChild);
    },
    
    /** insert a node as the last child of the parent */
    insertEnd: function(parent, node) {
        parent.appendChild(node);
    },
    
    /** insert a node before target */
    insertBefore: function(target, node) {
        target.parentNode.insertBefore(node, target);
    },
    
    /** insert a node after target */
    insertAfter: function(target, node) {
        target.parentNode.insertBefore(node, target.nextSibling);
    },
    
    //------------------------------------------------------------------------------
    //## multi insertions
    
    /** insert many nodes at the front of the children of the parent */
    insertBeginMany: function(parent, nodes) {
        var reference   = parent.firstChild;
        for (var i=0; i<nodes.length; i++) {
            parent.insertBefore(nodes[i], reference);
        }
    },
    
    /** insert many nodes at the end of the children of the parent */
    insertEndMany: function(parent, nodes) {
        for (var i=0; i<nodes.length; i++) {
            parent.appendChild(nodes[i]);
        }
    },
    
    /** insert many nodes before the target */
    insertBeforeMany: function(target, nodes) {
        var parent  = target.parentNode;
        for (var i=0; i<nodes.length; i++) {
            parent.insertBefore(nodes[i], target);
        }
    },
    
    /** insert many nodes after the target */
    insertAfterMany: function(target, nodes) {
        var parent      = target.parentNode;
        var reference   = target.nextSibling;
        for (var i=0; i<nodes.length; i++) {
            parent.insertBefore(nodes[i], reference);
        }
    },
    
    //------------------------------------------------------------------------------
    //## insertion helpers
    
    /** turn String into text node, pass through everything else */
    textAsNode: function(it) {
        return it.constructor === String
                ? document.createTextNode(it)
                : it;
    },
    
    /** textAsNode lifted to an Array */
    textAsNodeMany: function(its) {
        var out = [];
        for (var i=0; i<its.length; i++) {
            out.push(this.textAsNode(its[i]));
        }
        return out;
    },
    
    //------------------------------------------------------------------------------
    //## 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 target with a replacement node */
    replaceNode: function(target, replacement) {
        target.parentNode.replaceChild(replacement, target); 
    },
    
    /** replace a target with many replacement nodes */
    replaceNodeMany: function(target, replacements) {
        var parent  = target.parentNode;
        for (var i=0; i<replacements.length; i++) {
            parent.insertBefore(replacements[i], target);
        }
        parent.removeChild(target);
    },
    
    /** replace all children of a target node */
    replaceChildren: function(target, replacements) {
        this.removeChildren(target);
        this.insertEndMany(target, replacements);
    },

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

    // BETTER use StringSet here
    
    /** returns an Array of the classes of an element */
    getClasses: function(element) {
        var raw = (element.className || "").trim();
        return raw ? raw.split(/\s+/) : [];
    },
    
    /** sets all classes of an element from an Array of names */
    setClasses: function(element, classNames) {
        element.className   = classNames.join(" ");
    },
    
    /** pass the class names array to a function modifying it */
    modifyClasses: function(element, func) {
        this.setClasses(element, func(this.getClasses(element)));
    },

    /** returns whether an element has a class */
    hasClass: function(element, className) {
        return this.getClasses(element).contains(className);
    },

    /** adds a class to an element */
    addClass: function(element, className) {
        var set = this.getClasses(element);
        var ok  = !set.contains(className);
        if (ok) {
            set.push(className);
            this.setClasses(element, set);
        }
        return ok;
    },

    /** removes a class to an element */
    removeClass: function(element, className) {
        var set = this.getClasses(element);
        var ok  = set.contains(className);
        if (ok) {
            set.remove(className);
            this.setClasses(element, set);
        }
        return ok;
    },

    /** replaces a class in an element with another */
    replaceClass: function(element, oldClassName, newClassName) {
        var set = this.getClasses(element);
        if (set.contains(oldClassName)) {
            set.remove(oldClassName);
            if (!set.contains(newClassName)) {
                set.push(newClassName);
            }
        }
        this.setClasses(element, set);
    },
    
    /** sets or unsets a class on an element */
    updateClass: function(element, className, active) {
        if (active) this.addClass(element, className);
        else        this.removeClass(element, className);
    },
    
    /** switches between two different classes */
    switchClass: function(element, condition, trueClassName, falseClassName) {
        if (condition)  this.replaceClass(element, falseClassName, trueClassName);
        else            this.replaceClass(element, trueClassName, falseClassName);
    },

    //------------------------------------------------------------------------------
    //## position
    
    /** analog to element.scrollTop, but from the bottom */
    getScrollBottom: function(element) {
        return element.scrollHeight - element.offsetHeight - element.scrollTop;
    },

    /** analog to element.scrollTop, but from the bottom */
    setScrollBottom: function(element, scrollBottom) {
        element.scrollTop   = element.scrollHeight - element.offsetHeight - scrollBottom;
    },

    /** 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 

var jsutil  = jsutil || {};

/** helper functions for cookies */
jsutil.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)[1];
        if (!s) return null;
        s   = s.split(";")[0].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.toUTCString() +
                        "; 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 

var jsutil  = jsutil || {};

/** ajax helper */
jsutil.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 before the more specific functions
     *      noSuccessFunc       called in all non-successful cases
     *
     *      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 new Error("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 (args.completeFunc)  args.completeFunc(client);
            
            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 (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) {
                if (!args.headers.hasOwnProperty(key))  continue;
                client.setRequestHeader(key, args.headers[key]);
            }
        }
        
        // 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);
        
        return {
            client: client,
            aborted: false,
            abort:  function() {
                if (client.timer)   clearTimeout(client.timer);
                this.aborted    = true;
                try { client.abort(); }
                catch (e) {}    // TODO log this somewhere
            }//,
        };
    },
    
    //------------------------------------------------------------------------------
    //## 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[i];
            if (pair.constructor !== Array) throw new Error("expected a Pair: " + pair);
            if (pair.length !== 2)          throw new Error("expected a Pair: " + pair);
            if (pair[1] === null)   continue;
            out += "&"  + encodeFunc(pair[0])
                +  "="  + encodeFunc(pair[1]);
        }
        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) {
            if (!map.hasOwnProperty(key))   continue;
            var value   = map[key];
            if (value === null) continue;
            if (value.constructor === Array) {
                for (var i=0; i<value.length;i++) {
                    var subValue    = value[i];
                    if (subValue === null)  continue;
                    out.push([ key, subValue ]);
                }
                continue;
            }
            out.push([ key, value.toString() ]);
        }
        return out;
    }//,
};

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

var jsutil  = jsutil || {};

/** HTMLFormElement helper functions */
jsutil.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[nameOrIdOrIndex];
            else                                return null;
        }
        for (var i=0; i<forms.length; i++) {
            var form    = forms[i];
            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 jsutil.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[i];
            
            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 [ [ name, value          ] ];
            else        return [ [ name, "Submit Query" ] ];
        }
        else if (type === "button" || type === "image") {
            if (value)  return [ [ name, value          ] ];
            else        return [ [ name, ""             ] ];
        }
        else if (type === "checkbox" || type === "radio") {
                 if (!element.checked)  return [];
            else if (value !== null)    return [ [ name, value  ] ];
            else                        return [ [ name, "on"   ] ];    
        }
        else if (type === "select-one") {
            if (element.selectedIndex !== -1)   return [ [ name, value ] ];
            else                                return [];
        }
        else if (type === "select-multiple") {
            var pairs   = [];
            for (var i=0; i<element.options.length; i++) {
                var opt = element.options[i];
                if (!opt.selected)  continue;
                pairs.push([ name, opt.value ]);
            }
            return pairs;
        }
        else if (type === "text" || type === "password" || type === "hidden" || type === "textarea") {
            if (value)  return [ [ name, value  ] ];
            else        return [ [ name, ""     ] ];
        }
        else if (type === "file") {
            // NOTE: can't do anything here :(
            return [];
        }
        else {
            throw new Error("field: " + name + " has the unknown type: " + type);
        }
    }//,
};

//======================================================================
//## util/ext/function.js 

/** 
 * call this thunk after some millis.
 * optionally call the given continuation with the result afterwards.
 * returns an object with an cancel method to prevent this thunk from being called.
 * the cancel method returns whether cancellation was successful
 */
Function.prototype.callAfter = function(millis, continuation) {
    var self    = this;
    var running = false;
    function execute() { 
        running = true; 
        var out = self.call(); 
        if (continuation)   continuation(out);
    }
    var timer   = window.setTimeout(execute, millis);
    function cancel() {
        window.clearTimeout(timer);
        return !running;
    }
    return {
        cancel: cancel
    }; 
};

//======================================================================
//## util/ext/array.js 

//------------------------------------------------------------------------------
//## mutating

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

//------------------------------------------------------------------------------
//## not mutating

/** returns a new Array without the given element */
Array.prototype.cloneRemove = function(element) {
    var index   = this.indexOf(element);
    if (index === -1)   return this;
    var out = [].concat(this);
    out.splice(index, 1);
    return out;
};

/** filter with an inverted predicate */
Array.prototype.filterNot   = function(predicate, thisVal) {
    var len = this.length;
    var out = new Array();
    for (var i=0; i<len; i++) {
        var it  = this[i];
        if (!predicate.call(thisVal, it, i, this)) {
            out.push(it);
        }
    }
    return out;
};

/** zip with another array */
Array.prototype.zip = function(that) {
    var thisLen = this.length;
    var thatLen = that.length;
    var out = new Array();
    for (var i=0; i<thisLen && i<thatLen; i++) {
        out.push([ this[i], that[i] ]);
    }
    return out;
};

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

/** two partitions in a 2-element Array, first the partition where the predicate returned true */ 
Array.prototype.partition = function(predicate) {
    var yes = [];
    var no  = [];
    for (var i=0; i<this.length; i++) {
        var item    = this[i];
        (predicate(item) ? yes : no).push(item);
    }
    return [ yes, no ];
};

/** flatten an Array of Arrays into a simple Array */
Array.prototype.flatten = function() {
    var out = [];
    for (var i=0; i<this.length; i++) {
        out = out.concat(this[i]);
    }
    return out;
};

/** map every element to an Array and concat the resulting Arrays */
Array.prototype.flatMap = function(func, thisVal) {
    var out = [];
    for (var i=0; i<this.length; i++) {
        out = out.concat(func.call(thisVal, this[i], i, this));
    }
    return out;
};

/** returns a copy of this Array */
Array.prototype.clone = function() {
    return [].concat(this);
};

/** returns a reverse copy of this Array */
Array.prototype.cloneReverse = function() {
    var out = [].concat(this);
    out.reverse();
    return out;
};

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

/** optionally insert an element between every two elements and boundaries */
Array.prototype.inject = function(func, thisVal) {
    var out = [];
    for (var i=0; i<=this.length; i++) {
        var a   = i > 0             ? this[i-1] : null;
        var b   = i < this.length   ? this[i]   : null;
        var tmp     = func.call(thisVal, a, b);
        if (tmp !== null)   out.push(tmp);
        if (i < this.length)    out.push(this[i]);
    }
    return out;
};
        
/** use a function to extract keys and build an Object */
Array.prototype.mapBy = function(keyFunc) {
    var out = {};
    for (var i=0; i<this.length; i++) {
        var item    = this[i];
        out[keyFunc(item)]  = item;
    }
    return out;
};

//======================================================================
//## util/ext/string.js 

/** 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;
};

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

/** 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() + "([a-zA-Z]+?)" + suffix.escapeRE(), "g");
    return this.replace(re, function($0, $1) { 
        var arg = args[$1]; 
        return arg !== undefined ? arg : $0;
    });
};

//======================================================================
//## util/fill/nonstandard.js 

/** remove whitespace from the left */
if (!String.prototype.trimLeft)
        String.prototype.trimLeft = function() {
            return this.replace(/^\s+/gm, "");
        };
        
/** remove whitespace from the right */
if (!String.prototype.trimRight)
        String.prototype.trimRight = function() {
            return this.replace(/\s+$/gm, "");
        };

//======================================================================
//## util/fill/es6.js 

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

/** copies an object's properties into another object */
if (!Object.assign)
        Object.assign   = function(target, source) {
            for (var key in source) {
                if (source.hasOwnProperty(key)) {
                    target[key] = source[key];
                }
            }
        };

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

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

if (!Array.prototype.find)
        Array.prototype.find    = function(predicate, thisVal) {
            var len = this.length;
            for (var i=0; i<len; i++) {
                var it  = this[i];
                if (predicate.call(thisVal, it, i, this))   return it;
            }
            return undefined;
        };

if (!Array.prototype.findIndex)
        Array.prototype.findIndex   = function(predicate, thisVal) {
            var len = this.length;
            for (var i=0; i<len; i++) {
                var it  = this[i];
                if (predicate.call(thisVal, it, i, this))   return i;
            }
            return undefined;
        };
        
//------------------------------------------------------------------------------
//## String

/** whether this string starts with another */
if (!String.prototype.startsWith)
        String.prototype.startsWith = function(s, position) {
            var pos = position || 0;
            return this.indexOf(s, pos) === pos;
        };

/** whether this string ends with another */
if (!String.prototype.endsWith)
        String.prototype.endsWith   = function(s, position) {
            var pos = (position || this.length) - s.length;
            var idx = this.lastIndexOf(s);
            return idx !== -1 && idx === pos;
        };
        
/** whether this string contains another */
if (!String.prototype.contains)
        String.prototype.contains   = function(s, start) {
            return this.indexOf(s, start) !== -1;
        };

/** repeat this string count times */
if (!String.prototype.repeat)
        String.prototype.repeat = function(count) {
                 if (count < 0)     throw new Error("count must be greater or equal than zero");
            else if (count === 0)   return "";
            else {
                var out = "";
                for (var i=0; i<count; i++) {
                    out += this;
                }
                return out;
            }
        };

//======================================================================
//## lib/core/Wiki.js 

/** encoding and decoding of MediaWiki URLs */
var Wiki = {
    //## config
    
    /** the current wiki site without any path */
    site:       null,   // "http://de.wikipedia.org",
    
    /** the protocol of the current site */
    protocol:   null,   // "http://"
    
    /** the current wiki domain */
    domain:     null,   // "de.wikipedia.org",

    /** language of the site */
    language:   null,   // "de"
    
    /** name of the logged in user */
    user:       null,   

    /** private: path to read pages */
    readPath:   null,   // "/wiki/",

    /** private: path for page actions */
    actionPath: null,   // "/w/index.php",
    
    //## info
    
    /** whether user has news */
    haveNews: function() {
        // li#pt-mytalk a.mw-echo-unread-notifications.mw-echo-notifications-badge 
         return jsutil.DOM.fetch("pt-mytalk", null, "mw-echo-alert", 0) !== null;
    },
    
    /**
      * the bodyContent-equivalent for all skins.
      * parent is optional, for null the document is used 
      */
    bodyContent: function(parent) {
        parent = parent || document;
        return parent.getElementById('bodyContent')
            || parent.getElementById('mw_contentholder')
            || parent.getElementById('article');
    },
    
    //## URLs
    
    /** compute an URL in the read form without a title parameter. the args object is optional */
    readURL: function(title, args) {
        args    = jsutil.Object.clone(args);
        args.title  = title;
        return this.encodeURL(args, true);
    },

    /** encode parameters into an URL */
    encodeURL: function(args, shorten) {
        args    = jsutil.Object.clone(args);
        args.title  = this.normalizeTitle(args.title);

        // HACK: Special:Randompage _requires_ smushing!
        var specialInfo = Special.pageInfo(args.title);
        if (specialInfo) {
            if (shorten
            || specialInfo.canonicalName == "Randompage" && specialInfo.smushValue)
                    this.smush(args, specialInfo);
            else
                    this.desmush(args, specialInfo);
        }

        // create start path
        var path;
        if (shorten) {
            // "+" means "+" here!
            path    = this.readPath
                    + this.fixPlus(this.fixTitle(encodeURIComponent(args.title)));
            // not needed any more
            delete args.title;                          
        }
        else {
            path    = this.actionPath;
        }
        path    += "?";

        // normalize title-type parameters
        var normalizeParams = ["title"];
        if (specialInfo) {
            normalizeParams = normalizeParams.concat(specialInfo.titleParams);
        }
        for (var key in args) {
            var value   = args[key];
            if (value === null)     continue;

            var code    = encodeURIComponent(value.toString());
            if (normalizeParams.contains(key)) {
                code    = this.fixTitle(code);
            }
            
            path    += encodeURIComponent(key)
                    + "=" + code + "&";
        }

        return this.site + path.replace(/[?&]$/, "");
    },

    /**
      * decode an URL or path into a map of parameters. all titles are normalized.
      * if a specialpage has a smushed parameter, it is removed from the title
      * and handled like any other parameter.
      */
    decodeURL: function(url, titleFallback) {
        var args    = {};
        var loc     = new jsutil.Loc(url);
        
        // TODO check protocol, host and port

        // readPath has the title directly attached, "+" means "+" here!
        if (loc.pathname !== this.actionPath) {
            var read    = loc.pathname.scan(this.readPath);
            if (!read)  throw "cannot decode: " + url;
            args.title  = decodeURIComponent(read);
        }

        // decode all parameters, "+" means " "
        if (loc.search) {
            var split   = loc.search.substring(1).split("&");
            for (i=0; i<split.length; i++) {
                var parts   = split[i].split("=");
                if (parts.length !== 2) continue;
                var key     = decodeURIComponent(parts[0]);
                var code    = parts[1].replace(/\+/g, "%20");
                args[key]   = decodeURIComponent(code)
            }
        }

        if (!args.title)    args.title  = titleFallback;
        if (!args.title)    throw "decode: missing page title in: " + loc;
        args.title  = this.normalizeTitle(args.title);
        
        // normalize title-type parameters and desmush
        var specialInfo     = Special.pageInfo(args.title);
        if (specialInfo) {
            var normalizeParams = specialInfo.titleParams;
            for (var i=0; i<normalizeParams.length; i++) {
                var key     = normalizeParams[i];
                var value   = args[key];
                if (value)  args[key]   = this.normalizeTitle(value);
            }
            this.desmush(args, specialInfo);
        }
        return args;
    },
    
    //## hacks
    
    /** returns a smushed copy of a params object */
    smushedParams: function(args) {
        var out         = jsutil.Object.clone(args);
        var specialInfo = Special.pageInfo(this.normalizeTitle(args.title));
        this.smush(out, specialInfo);
        return out;
    },
    
    /** returns the owner for a Page */
    owner: function(args) {
        var self    = this;
        
        function names(title) {
            if (!title) return null;
            var tmp = Namespace.scan(2, title) || Namespace.scan(3, title);
            if (!tmp)   return null;
            return tmp.replace(/\/.*/, "");
        }
        
        function special() {
            var specialInfo = Special.pageInfo(self.normalizeTitle(args.title));
            if (!specialInfo)   return null;
            
            var desmushed   = jsutil.Object.clone(args);
            self.desmush(desmushed, specialInfo);
            
            if (specialInfo.canonicalName === "EmailUser")              return desmushed.target;
            if (specialInfo.canonicalName === "Contributions")          return desmushed.target;
            if (specialInfo.canonicalName === "DeletedContributions")   return desmushed.target;
            if (specialInfo.canonicalName === "BlockIP")                return desmushed.ip;
            
            if (specialInfo.canonicalName === "WhatLinksHere")          return names(desmushed.target);
            if (specialInfo.canonicalName === "RecentChangesLinked")    return names(desmushed.target);
            if (specialInfo.canonicalName === "Undelete")               return names(desmushed.target);
            if (specialInfo.canonicalName === "Log")                    return names(desmushed.page);
            
            // TODO Userrights MovePage CheckUser UserLogin Preferences Export
            return null;
        }
        
        return names(args.title) || special();
    },
            
    //------------------------------------------------------------------------------
    //## private

    /** replaces Special:Page?key=value with Special:Page/value */
    smush: function(args, specialInfo) {
        var param   = specialInfo.smushParam;
        if (param && args.hasOwnProperty(param)) {
            args.title  += "/" + args[param];
            delete args[param];
        }
    },

    /** replaces Special:Page/value with Special:Page?key=value */
    desmush: function(args, specialInfo) {
        var param   = specialInfo.smushParam;
        var value   = specialInfo.smushValue;
        if (param && value) {
            args.title  = Titles.specialPage(specialInfo.localName);
            args[param] = value;
        }
    },

    /** to the user, all titles use " " instead of "_" */
    normalizeTitle: function(title) {
        return title.replace(/_/g, " ");
    },

    /** some characters are encoded differently in titles */
    fixTitle: jsutil.Text.recoder([
        [ "%%",     "%%"    ],
        [ "%3a",    ":"     ],
        [ "%3A",    ":"     ],
        [ "%2f",    "/"     ],
        [ "%2F",    "/"     ],
        [ "%20",    "_"     ],
        [ "%5f",    "_"     ],
        [ "%5F",    "_"     ]//,
    ]),
    
    /** in read urls the title uses literal "+" */
    fixPlus: jsutil.Text.recoder([
        [ "%%",     "%%"    ],
        [ "%2b",    "+"     ],
        [ "%2B",    "+"     ]//,
    ]),

    //------------------------------------------------------------------------------

    /** to be called onload */
    init: function() {
        // for some reason the http: prefix is missing on commons
        var hackServer  = wgServer.replace(/^\/\//, location.protocol + "//");
        this.site       = hackServer;
        var m   = /([^\/]+\/+)(.*)/.exec(hackServer);
        this.protocol   = m[1];
        this.domain     = m[2];
        this.language   = wgContentLanguage;
        this.user       = wgUserName;
        this.readPath   = wgArticlePath.replace(/\$1$/, "");
        this.actionPath = wgScriptPath + "/index.php";
        
        Special.init(this.language);
    }//,
};

//======================================================================
//## lib/core/Titles.js 

// NOTE: named Titles instead of Title to avoid a nameclash with lupin's popups
var Titles = {
    /** generates a page title. nsIndex and subPage are optional */
    page: function(nsIndex, page, subPage) {
        var title   = page;
        if (nsIndex !== null)   title   = Namespace.add(nsIndex, title);
        if (subPage)            title   = title + "/" + subPage;
        return title;
    },
    
    /** create a localized page title from the canonical special page name and an optional parameter */
    specialPage: function(name, param) {
        var specialNames    = Special.pageNames(name);
        if (!specialNames)  throw "cannot localize specialpage: " + name;
        return this.page(-1, specialNames.localName, param);
    },
    
    /** generates a user page title. subPage is optional */
    userPage: function(user, subPage) {
        return this.page(2, user, subPage);
    },
    
    /** generates a user talk page title. subPage is optional */
    userTalkPage: function(user, subPage) {
        return this.page(3, user, subPage);
    }//,
};

//======================================================================
//## lib/core/Page.js 

/** represents the current Page */
var Page = {
    /** search string of the current location decoded into an Array */
    params:     null,

    /** the namespace of the current page */
    namespace:  null,

    /** title for the current URL ignoring redirects */
    title:      null,

    /** permalink to the current page if one exists or null */
    perma:      null,

    /** whether this page could be deleted */
    deletable:  false,

    /** whether this page could be edited */
    editable:   false,

    /** the user a User or User_talk or Special:Contributions page belongs to */
    owner:      null,
    
    /** the existing user this page belongs to, or null */
    ownerExists:    null,
    
    //------------------------------------------------------------------------------
    //## public
    
    /** returns the canonical name of the current Specialpage or null */
    whichSpecial: function() {
        return this.specialInfo && this.specialInfo.canonicalName;
    },
    
    /** other languages of this page */
    languages: function() {
        var languages   = [];
        var pLang   = jsutil.DOM.get('p-lang');
        if (!pLang) return languages;
        var lis = pLang.getElementsByTagName("li");
        for (var i=0; i<lis.length; i++) {
            var li      = lis[i];
            var a       = li.firstChild;
            if (!a) continue;
            languages.push({
                code:   li.className.replace(/^interwiki-/, ""),
                name:   a.textContent,
                href:   a.href//,
            });
        }
        return languages;
    },
    
    //------------------------------------------------------------------------------
    //## private

    /** SpecialInfo object */
    specialInfo:    null,
    
    /** to be called onload */
    init: function() {
        this.params = Wiki.decodeURL(window.location.href, wgPageName);

        // wgNamespaceNumber / wgCanonicalNamespace
        var m   = /(^| )ns-(-?[0-9]+)( |$)/.exec(document.body.className);
        if (m)  this.namespace  = parseInt(m[2]);
        // else error

        // wgPageName / wgTitle
        this.title      = this.params.title;

        this.deletable  = jsutil.DOM.get('ca-delete') !== null;
        this.editable   = jsutil.DOM.get('ca-edit')   !== null;
        
// #t-permalink
        var tPermalink  = jsutil.DOM.fetch('t-permalink', "a", null, 0);
        if (tPermalink !== null)    this.perma  = tPermalink.href;
        
        // title is already normalized
        this.specialInfo    = Special.pageInfo(this.params.title);

        // standard way
        this.owner  = Wiki.owner(this.params);
        
        // try to find a really existing owner
        var self    = this;
        this.ownerExists = (function() {
            var tBlockip        = jsutil.DOM.fetch('t-blockip', "a", null, 0);
            if (tBlockip)       return Wiki.decodeURL(tBlockip.href).ip;
            var tContributions  = jsutil.DOM.fetch('t-contributions', "a", null, 0);
            if (tContributions) return Wiki.decodeURL(tContributions.href).target;
            
            if (!self.specialInfo)  return null;
            if (self.specialInfo.canonicalName !== "Contributions" 
                    && self.specialInfo.canonicalName !== "DeletedContributions")   return null;
            
            // block and deleted exist for admins only
            // nonexisting: _    talk                logs deleted
            // ip:          _    talk block blocklog logs deleted
            // user:        user talk block blocklog logs deleted
            
            
// if a blocklog-link exists, the user exists, too.
            var as  =  jsutil.DOM.fetch('contentSub', "a"); 
            for (var i=0; i<as.length; i++) {
                var logA    = as[i];
                var logArgs = Wiki.decodeURL(logA.href);
                var logInfo = Special.pageInfo(logArgs.title);
                if (!logInfo)   continue;
                if (logInfo.canonicalName === "Log"
                        && logArgs.type === "block")    return self.params.target;
            }
            
            return null;
        })();
    }//,
};

//======================================================================
//## lib/core/Namespace.js 

/** namespaces of the current Wiki */
var Namespace = {
    // TODO check canonical
    // TODO allow spaces around the colon (?)
    
    /** the local name of a namespace */
    name: function(nsIndex) {
        if (!wgFormattedNamespaces.hasOwnProperty(nsIndex)) throw "unknown Namespace: " + nsIndex;
        return wgFormattedNamespaces[nsIndex];
    },
    
    /** put a page into a namespace */
    add: function(nsIndex, page) {
        return this.name(nsIndex) + ":" + page;
    },
    
    /** calculate page title without the namespace or return null */
    scan: function(nsIndex, title) {
        return title.scanNoCase(this.name(nsIndex) + ":");
    },
    
    /** returns an object with internal nsIndex and page  */
    split: function(title) {
        var split   = title.split(/:/);
        if (split.length > 1) {
            var prefix  = split[0].toLowerCase();
            if (wgNamespaceIds.hasOwnProperty(prefix)) {
                return {
                    nsIndex:    wgNamespaceIds[prefix],
                    page:       title.replace(/.*?:/, "")//,
                };
            }
        }
        return {
            nsIndex:    0,
            page:       title//,
        };
    }//,
    
};

//======================================================================
//## lib/core/Special.js 

/** special page specialities */
var Special = {
    /** 
     * returns an information object for titles like Special:Name or Special:Name/param
     * or null for unknown specialPages or non-specialPages.
     *   
     * localName        the localized name
     * canonicalName    the canonical name
     * smushParam       which parameter may be sushed to this specialPages's title
     * param            the value of the smushed parameter, or null if none
     * titleParams      an Array of parameter names for page titles
     */
    pageInfo: function(normalizedTitle) {
        var title   = Namespace.scan(-1, normalizedTitle);
        if (title === null) return null;
        
        // split after the first slash
        var m = /(.*?)\/(.*)/.exec(title);
        var name;
        var param;
        var localName;
        if (m) {
            name    = m[1];
            param   = m[2];
        }
        else {
            name    = title;
            param   = null;
        }
        
        // NOTE: name may be canonical or local
        var names           = this.pageNames(name);
        if (names == null)  throw "could not localize special page: " + name + " for title: " + normalizedTitle;
        
        var localName       = names.localName;
        var canonicalName   = names.canonicalName;
        
        var titleParams = this.titleParams[canonicalName] || [];
        var smushParam  = this.smush[canonicalName];
        
        return {
            localName:      localName,
            canonicalName:  canonicalName,
            smushParam:     smushParam,
            smushValue:     param,
            titleParams:    titleParams
        };
    },
    
    //------------------------------------------------------------------------------
    //## private
    
    /** maps lowercased canonical special page names to localized name */
    localPages:     null,
    
    /** maps lowercased localized special page names to canonical name */
    canonicalPages: null,
    
    init: function(language) {
        var pages   = this.pages[language];
        if (!pages) throw "unconfigured pages language: " + language;
        
        // init forward and inverse name maps
        this.localPages     = {};
        this.canonicalPages = {};
        for (key in pages) {
            var value   = pages[key];
            this.localPages[key.toLowerCase()]          = value;
            this.canonicalPages[value.toLowerCase()]    = key;
        }
    },
    
    /** returns an object containing localName and canonicalName or null for unknown pages */
    pageNames: function(name) {
        var canonicalName   = this.canonicalPages[name.toLowerCase()];
        var localName       = this.localPages[name.toLowerCase()];
        if (!canonicalName && !localName)   return null;
        if (!canonicalName) canonicalName   = this.canonicalPages[localName.toLowerCase()];
        if (!localName)     localName       = this.localPages[canonicalName.toLowerCase()];
        return {
            localName:      localName,
            canonicalName:  canonicalName//,
        };
    },
    
    /** maps language to wgCanonicalSpecialPageName to wgTitle */
    pages: {
        de: {
            // TODO "Benutzerkonto anlegen" redirects to "Anmelden/signup"
            "CreateAccount":            "Benutzerkonto anlegen",
            "DisambiguationPages":      "Begriffsklärungsseiten",
            "Nearby":                   "In der Nähe",
            "PagesWithProp":            "Seiten mit Eigenschaften",
            "DisambiguationPageLinks":  "Begriffsklärungslinks",
            "Notifications":            "Benachrichtigungen",
            "OAuthListConsumers":       "Verbraucher auflisten",
            "OAuthManageMyGrants":      "Meine Berechtigungen verwalten",
            "Thanks":                   "Danke",
            
            "ValidationStatistics":     "Sichtungsstatistik",
            "ArticleFeedbackv5":        "Artikelrückmeldungen v5",
            
            "QualityOversight":         "QualityOversight",
            "PendingChanges":           "PendingChanges",
            "SimpleSurvey":             "SimpleSurvey",
            "ArticleFeedback":          "ArticleFeedback",
            "FeedbackDashboard":        "FeedbackDashboard",
            
            "ApiSandbox":               "API-Spielwiese",
            "PasswordReset":            "Passwort neu vergeben",
            "Hieroglyphs":              "Hieroglyphen",
            "Interwiki":                "Interwikitabelle",
            "ZeroRatedMobileAccess":    "ZeroRatedMobileAccess",
            "ChangeEmail":              "E-Mail-Adresse ändern",
            
            "Unblock":                  "Freigeben",
            // "UploadWizard":              "UploadWizard", // does not exist on de.wikipedia
            "Protectedtitles":          "Geschützte Titel",
            "CentralAuth":              "Verwaltung Benutzerkonten-Zusammenführung",
            "WikiSets":                 "Wikigruppen",
            "ComparePages":             "Seiten vergleichen",
            
            "ConfiguredPages":          "ConfiguredPages",
            "Revisiondelete":           "Versionslöschung",
            "ProblemChanges":           "Seiten mit problematischen Versionen",
            "PrefStats":                "PrefStats",
            "Mypage":                   "Meine Benutzerseite",
            
            "Nuke":                     "Massenlöschung",
            "Activeusers":              "Aktive Benutzer",
            "PrefSwitch":               "UsabilityInitiativePrefSwitch",
            // "UsabilityInitiativeOptIn":  "UsabilityInitiativeOptIn", 
            "GlobalGroupPermissions":   "Globale Gruppenrechte",
            "Wantedfiles":              "Gewünschte Dateien",
            "Wantedtemplates":          "Gewünschte Vorlagen",
            "Resetpass":                "Passwort ändern",
            "Tags":                     "Markierungen",
            "AbuseLog":                 "Missbrauchsfilter-Logbuch",
            "Book":                     "Buch",
            "UnstablePages":            "Unstabile Seiten",
            
            "AbuseFilter":              "Missbrauchsfilter",
            "ValidationStatistics":     "Markierungsstatistik",
            "GlobalBlockStatus":        "Ausnahme von globaler Sperre",
            
            "AutoLogin":                "AutoLogin",
            "Captcha":                  "Captcha",
            "MergeHistory":             "Versionsgeschichten vereinen",
            "GlobalBlockList":          "Liste globaler Sperren",
            
            "Userrights":               "Benutzerrechte",
            "ReviewedPages":            "Gesichtete Seiten",
            "StablePages":              "Konfigurierte Seiten",
            "QualityOversight":         "Markierungsübersicht",
            "OldReviewedPages":         "Seiten mit ungesichteten Versionen",
            "RevisionReview":           "RevisionReview",
            "UnreviewedPages":          "Ungesichtete Seiten",
            "DepreciationOversight":    "Zurückgezogene Markierungen",
            
            "Listgrouprights":          "Gruppenrechte",
            "Mostlinkedtemplates":      "Meistbenutzte Vorlagen",
            "Uncategorizedtemplates":   "Nicht kategorisierte Vorlagen",
            "Gadgets":                  "Helferlein",
            "GlobalUsers":              "Globale Benutzerliste",
            "ParserDiffTest":           "Parser-Differenz-Test",    //### the link title on Special:SpecialPages is ParserDiffTest on wp:de
            
            "MergeAccount":         "Benutzerkonten zusammenführen",
            "FileDuplicateSearch":  "Dateiduplikatsuche",
            "DeletedContributions": "Gelöschte Beiträge",
            
            "Contributions":        "Beiträge",
            "SpecialPages":         "Spezialseiten",
            "Emailuser":            "E-Mail senden",
            "Whatlinkshere":        "Linkliste",
            "MovePage":             "Verschieben",
            "CheckUser":            "CheckUser",
            "Recentchangeslinked":  "Änderungen an verlinkten Seiten",
            "Protectedpages":       "Geschützte Seiten",
            "Fewestrevisions":      "Wenigstbearbeitete Seiten",
            "Withoutinterwiki":     "Fehlende Interwikis",

            "Allpages":             "Alle Seiten",
            "Userlogin":            "Anmelden",
            "CrossNamespaceLinks":  "Seiten mit Links in andere Namensräume",
            
            "Mostrevisions":        "Meistbearbeitete Seiten",
            "Disambiguations":      "Begriffsklärungsverweise",
            "Listusers":            "Benutzer",
            "Wantedcategories":     "Gewünschte Kategorien",
            "Watchlist":            "Beobachtungsliste",
            "Listfiles":            "Dateien",
            "Filepath":             "Dateipfad",
            
            "DoubleRedirects":      "Doppelte Weiterleitungen",
            "Preferences":          "Einstellungen",
            "Wantedpages":          "Gewünschte Seiten",
            "Upload":               "Hochladen",
            "Mostlinked":           "Meistverlinkte Seiten",
            "Booksources":          "ISBN-Suche",
            "BrokenRedirects":      "Defekte Weiterleitungen",
            "Categories":           "Kategorien",
            "CategoryTree":         "Kategorienbaum",
            
            "Shortpages":           "Kürzeste Seiten",
            "LongPages":            "Längste Seiten",
            "Recentchanges":        "Letzte Änderungen",
            "Ipblocklist":          "Liste der Sperren",
            "SiteMatrix":           "Liste der Wikimedia-Wikis",
            "Log":                  "Logbuch",
            "Mostcategories":       "Meistkategorisierte Seiten",
            "Mostimages":           "Meistbenutzte Dateien",
            "Mostlinkedcategories": "Meistbenutzte Kategorien",
            
            "Newpages":             "Neue Seiten",
            "Newimages":            "Neue Dateien",
            "Unusedtemplates":      "Unbenutzte Vorlagen",
            "Uncategorizedpages":   "Nicht kategorisierte Seiten",
            "Uncategorizedimages":  "Nicht kategorisierte Dateien",
            "Uncategorizedcategories":  "Nicht kategorisierte Kategorien",
            "Prefixindex":          "Präfixindex",
            "Deadendpages":         "Sackgassenseiten",
            "Ancientpages":         "Älteste Seiten",
            
            "Export":               "Exportieren",
            "Allmessages":          "MediaWiki-Systemnachrichten",
            "Statistics":           "Statistik",
            "Search":               "Suche",
            "MIMEsearch":           "MIME-Typ-Suche",
            "Version":              "Version",
            "Unusedimages":         "Unbenutzte Dateien",
            "Unusedcategories":     "Unbenutzte Kategorien",
            "Lonelypages":          "Verwaiste Seiten",
            
            "ExpandTemplates":      "Vorlagen expandieren",
            "Boardvote":            "Boardvote",
            "LinkSearch":           "Weblinksuche",
            "Listredirects":        "Weiterleitungen",
            "Cite":                 "Zitierhilfe",
            "RandomRedirect":       "Zufällige Weiterleitung",
            "Random":               "Zufällige Seite",
            
            "Undelete":             "Wiederherstellen",
            "BlockIP":              "Sperren",
            "UnwatchedPages":       "Ignorierte Seiten",
            "Import":               "Importieren"//,
        },
        // en is (mostly) canonical, so this is almost an identity mapping
        en: {
            // TODO "???" redirects to "Userlogin/signup"
            "CreateAccount":            "CreateAccount",
            "DisambiguationPages":      "DisambiguationPages",
            "Nearby":                   "Nearby",
            "PagesWithProp":            "PagesWithProp",
            "DisambiguationPageLinks":  "DisambiguationPageLinks",
            "Notifications":            "Notifications",
            "OAuthListConsumers":       "OAuthListConsumers",
            "OAuthManageMyGrants":      "OAuthManageMyGrants",
            "Thanks":                   "Thanks",
            
            "ValidationStatistics":     "ValidationStatistics",
            "ArticleFeedbackv5":        "ArticleFeedbackv5",
            
            "QualityOversight":         "AdvancedReviewLog",
            "PendingChanges":           "PendingChanges",
            "SimpleSurvey":             "SimpleSurvey",
            "ArticleFeedback":          "ArticleFeedback",
            "FeedbackDashboard":        "FeedbackDashboard",
            
            "ApiSandbox":               "ApiSandbox",
            "PasswordReset":            "PasswordReset",
            "Hieroglyphs":              "Hieroglyphs",
            "Interwiki":                "Interwiki",
            "ZeroRatedMobileAccess":    "ZeroRatedMobileAccess",
            "ChangeEmail":              "ChangeEmail",
            
            "Unblock":                  "Unblock",
            "UploadWizard":             "UploadWizard",
            "CentralAuth":              "CentralAuth",
            "WikiSets":                 "WikiSets",
            "ComparePages":             "ComparePages",
            
            "ConfiguredPages":          "ConfiguredPages",
            "Revisiondelete":           "Revisiondelete",
            "ProblemChanges":           "ProblemChanges",
            "PrefStats":                "PrefStats",
            "Mypage":                   "Mypage",
            
            "Nuke":                     "Nuke",
            "Activeusers":              "Activeusers",
            "PrefSwitch":               "UsabilityInitiativePrefSwitch",
            "UsabilityInitiativeOptIn": "UsabilityInitiativeOptIn", 
            "GlobalGroupPermissions":   "GlobalGroupPermissions",
            "Wantedfiles":              "WantedFiles",
            "Wantedtemplates":          "Wantedtemplates",
            "Resetpass":                "ChangePassword",
            "Tags":                     "Tags",
            "AbuseLog":                 "AbuseLog",
            "Book":                     "Book",
            "UnstablePages":            "UnstablePages",
            
            "AbuseFilter":              "AbuseFilter",
            "ValidationStatistics":     "ValidationStatistics",
            "GlobalBlockStatus":        "GlobalBlockStatus",
            
            "AutoLogin":                "AutoLogin",
            "Captcha":                  "Captcha",
            "MergeHistory":             "Versionsgeschichten vereinen",
            "GlobalBlockList":          "GlobalBlockList",
            
            "Userrights":               "Userrights",
            "ReviewedPages":            "ReviewedPages",
            "StablePages":              "StablePages",
            "QualityOversight":         "QualityOversight",
            "OldReviewedPages":         "OldReviewedPages",
            "RevisionReview":           "RevisionReview",
            "UnreviewedPages":          "UnreviewedPages",
            "DepreciationOversight":    "DepreciationOversight",
            
            "Protectedtitles":          "ProtectedTitles",          // attention, not equal
            "Listgrouprights":          "ListGroupRights",          // attention, not equal
            "Mostlinkedtemplates":      "MostLinkedTemplates",      // attention, not equal
            "Uncategorizedtemplates":   "UncategorizedTemplates",   // attention, not equal
            "Gadgets":                  "Gadgets",
            "GlobalUsers":              "GlobalUsers",
            "ParserDiffTest":           "ParserDiffTest",
            
            "MergeAccount":         "MergeAccount",
            "FileDuplicateSearch":  "FileDuplicateSearch",
            "DeletedContributions": "DeletedContributions",
            
            "Contributions":        "Contributions",
            "SpecialPages":         "SpecialPages",
            "Emailuser":            "EmailUser",
            "Whatlinkshere":        "WhatLinksHere",
            "MovePage":             "MovePage",
            "CheckUser":            "CheckUser",
            "Recentchangeslinked":  "RecentChangesLinked",
            "Protectedpages":       "ProtectedPages",
            "Fewestrevisions":      "FewestRevisions",
            "Withoutinterwiki":     "WithoutInterwiki",
            
            "Allpages":             "AllPages",
            "Userlogin":            "UserLogin",
            "CrossNamespaceLinks":  "CrossNamespaceLinks",
            
            "Mostrevisions":        "MostRevisions",
            "Disambiguations":      "Disambiguations",
            "Listusers":            "ListUsers",
            "Wantedcategories":     "WantedCategories",
            "Watchlist":            "Watchlist",
            "Listfiles":            "ListFiles",    // attention, not equal
            "Filepath":             "FilePath",
            
            "DoubleRedirects":      "DoubleRedirects",
            "Preferences":          "Preferences",
            "Wantedpages":          "WantedPages",
            "Upload":               "Upload",
            "Mostlinked":           "MostLinkedPages",
            "Booksources":          "BookSources",
            "BrokenRedirects":      "BrokenRedirects",
            "Categories":           "Categories",
            "CategoryTree":         "CategoryTree",
            
            "Shortpages":           "ShortPages",
            "LongPages":            "LongPages",
            "Recentchanges":        "RecentChanges",
            "Ipblocklist":          "BlockList",
            "SiteMatrix":           "SiteMatrix",
            "Log":                  "Log",
            "Mostcategories":       "MostCategories",
            "Mostimages":           "MostLinkedFiles",
            "Mostlinkedcategories": "MostLinkedCategories",
            
            "Newpages":             "NewPages",
            "Newimages":            "NewFiles", // attention, not equal
            "Unusedtemplates":      "UnusedTemplates",
            "Uncategorizedpages":   "UncategorizedPages",
            "Uncategorizedimages":  "UncategorizedFiles",
            "Uncategorizedcategories":  "UncategorizedCategories",
            "Prefixindex":          "PrefixIndex",
            "Deadendpages":         "DeadendPages",
            "Ancientpages":         "AncientPages",
            
            "Export":               "Export",
            "Allmessages":          "AllMessages",
            "Statistics":           "Statistics",
            "Search":               "Search",
            "MIMEsearch":           "MIMESearch",
            "Version":              "Version",
            "Unusedimages":         "UnusedFiles",
            "Unusedcategories":     "UnusedCategories",
            "Lonelypages":          "LonelyPages",
            
            "ExpandTemplates":      "ExpandTemplates",
            "Boardvote":            "Boardvote",
            "LinkSearch":           "Linksearch",
            "Listredirects":        "ListRedirects",
            "Cite":                 "Cite",
            "RandomRedirect":       "RandomRedirect",
            "Random":               "Random",
            
            "Undelete":             "Undelete",
            "BlockIP":              "BlockIP",
            "UnwatchedPages":       "UnwatchedPages",
            "Import":               "Import"//,
        }//,
    },
    
    /** some parameters of special pages point to pages, in this case space and underscore mean the same */
    titleParams: {
        "EmailUser":                [ "target"      ],
        "Contributions":            [ "target"      ],
        "DeletedContributions":     [ "target"      ],
        "Whatlinkshere":            [ "target"      ],
        "Recentchangeslinked":      [ "target"      ],
        "Undelete":                 [ "target"      ],
        "Allpages":                 [ "from"        ],
        "Prefixindex":              [ "from"        ],
        "BlockIP":                  [ "ip"          ],
        "Log":                      [ "page"        ],
        "Filepath":                 [ "file"        ],
        "Randompage":               [ "namespace"   ]//,
    },
    
    /** some special pages can smush one parameter to the page title */
    smush: {
        "EmailUser":            "target",
        "Contributions":        "target",
        "DeletedContributions": "target",
        "Whatlinkshere":        "target",
        "Recentchangeslinked":  "target",
        "Undelete":             "target",
        "LinkSearch":           "target",
        "Newpages":             "limit",
        "Newimages":            "limit",
        "Wantedpages":          "limit",
        "Recentchanges":        "limit",
        "Allpages":             "from",
        "Prefixindex":          "from",
        "Log":                  "type",
        "BlockIP":              "ip",
        "Listusers":            "group",
        "Filepath":             "file",
        "Randompage":           "namespace",
        "Watchlist":            "action",
        // Contributions
        //      a smushed /newbies does not mean a user named "newbies"!
        // Randompage
        //      the namespace parameter is fake, it exists only in smushed form
    }//,
};

//======================================================================
//## 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 = jsutil.Text.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 = jsutil.Text.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  = jsutil.Function.identity;
        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["wpSummary"].value        = summary;
            }
            if (minor !== null && !!form.elements["wpMinoredit"]) {
                form.elements["wpMinoredit"].checked    = minor;
            }
            var text    = form.elements["wpTextbox1"].value;
            if (textFunc) {
                text    = text.replace(/^[\r\n]+$/, "");
                text    = textFunc(text);
                if (text === null) { feedback.failure("aborted"); return false; }
            }
            form.elements["wpTextbox1"].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[key]; }
                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[i];
                    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 args    = {
            title:  title,
            action: action//,
        };
        function change(form, doc) {
            return true;
        }
        this.action(feedback, args, 0, change, 200, doneFunc);
        
},

    /** 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["wpOldTitle"].value           = oldTitle;
            form.elements["wpNewTitle"].value           = newTitle;
            form.elements["wpReason"].value             = reason;
            if (form.elements["wpMovetalk"])
            form.elements["wpMovetalk"].checked         = moveTalk;
            if (form.elements["wpMovesubpages"])
            form.elements["wpMovesubpages"].checked     = moveSub;
            if (form.elements["wpLeaveRedirect"])
            form.elements["wpLeaveRedirect"].checked    = leaveRedirect;
            form.elements["wpWatch"].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 " + 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();
        }
        jsutil.Ajax.call({
            method:         "GET",
            url:            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  =  jsutil.Text.joinPrintable(" - ", [ 
                                reason,
                                form.elements["wpReason"].value ]);
            }
            else {
                reason  = "";
            }
            form.elements["wpReason"].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  = jsutil.Text.joinPrintable(" - ", [ 
                                reason,
                                form.elements["wpReason"].value ]);
            }
            else {
                reason  = "";
            }
            form.elements["wpReason"].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["mwProtect-level-edit"])
            form.elements["mwProtect-level-edit"].value     = levelEdit;    // plus mwProtectExpirySelection-edit named wpProtectExpirySelection-edit
            if (form.elements["mwProtect-edit-expires"])
            form.elements["mwProtect-edit-expires"].value   = expiryEdit;   // named mwProtect-expiry-edit
            
            // for existing pages
            if (form.elements["mwProtect-level-move"])
            form.elements["mwProtect-level-move"].value     = levelMove;    // plus mwProtectExpirySelection-move named wpProtectExpirySelection-move
            if(form.elements["mwProtect-move-expires"])
            form.elements["mwProtect-move-expires"].value   = expiryMove;   // named mwProtect-expiry-move
            
            // for deleted pages
            if (form.elements["mwProtect-level-create"])
            form.elements["mwProtect-level-create"].value   = levelCreate;  // plus mwProtectExpirySelection-create named wpProtectExpirySelection-create
            if (form.elements["mwProtect-create-expires"])
            form.elements["mwProtect-create-expires"].value = expiryMove;   // named mwProtect-expiry-create
        
         
            // for both deleted and existing pages
            form.elements["mwProtect-cascade"].checked  = cascade;
            form.elements["mwProtect-reason"].value         = reason;   // plus wpProtectReasonSelection    
            form.elements["mwProtectWatch"].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. 
     * createAccount defaults to true
     * hardBlock, autoBlock, disableEmail and disableUTEdit default to false
     * hardBlock makes sense for ip users, autoBlock for logged in users
     * expiry may be "indefinite", 
     * or a number followed by a space and 
     * "years", "months", "days", "hours" or "minutes"
     */
    blockUser: function(feedback, user, expiry, reason, autoBlock, hardBlock, createAccount, disableEmail, disableUTEdit, watchUT, 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) {
            var hasError    = form.getElementsByClassName("error").length !== 0;
            if (!allowChange && hasError)   return false;
            form.elements["wpTarget"].value             = user;
            form.elements["wpExpiry-other"].value       = expiry;
            form.elements["wpReason-other"].value       = reason;
            form.elements["wpCreateAccount"].checked    = createAccount;
            form.elements["wpDisableEmail"].checked     = disableEmail;
            form.elements["wpDisableUTEdit"].checked    = disableUTEdit;
            form.elements["wpAutoBlock"].checked        = autoBlock;
            form.elements["wpHardBlock"].checked        = hardBlock;
            form.elements["wpWatch"].checked            = watchUT;
            // TODO wpConfirm to true for self-blocking
            return true;
        }
        this.action(feedback, args, 0, 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["wpSubject"].value    = subject;
            form.elements["wpText"].value       = body;
            form.elements["wpCCMe"].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 = jsutil.XML.parseXML(text);
            }
            catch (e) {
                feedback.failure("cannot parse XML: " + e);
                return;
            }
            if (doc.getElementById('wikiPreview')) {
                feedback.failure("cannot save, preview detected");
                return;
            }
            var form    = jsutil.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 " + wgScript + " with " + self.debugArgsString(actionArgs));
            jsutil.Ajax.call({
                method:         "GET",
                url:            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 = jsutil.XML.parseXML(source.responseText);
            }
            catch (e) {
                feedback.failure("cannot parse XML: " + e);
                return;
            }
            
            // get form
            var form    = jsutil.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.getAttribute("action");
            var data    = jsutil.Form.serialize(form);
            feedback.work("POST " + url);
            jsutil.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     = wgScriptPath + "/api.php";
            var bodyParams  = {
                format: "json"//,
            };
            Object.assign(bodyParams, args);
            feedback.work("POST " + apiPath);
            jsutil.Ajax.call({
                url:            apiPath,
                method:         "POST",
                bodyParams:     bodyParams,
                successFunc:    phase2,
                noSuccessFunc:  failure//,
            });
        }
        function phase2(source) {
            var text    = source.responseText;
            var json;
            try {
                json    = JSON.parse(text);
            }
            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[key];
            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);
    }//,
};

//======================================================================
//## lib/core/Markup.js 

/** WikiText generator */
var Markup = {
    //------------------------------------------------------------------------------
    //## complex
    
    /** make a link to a user page */
    userLink: function(user, label) {
        return this.link(Titles.userPage(user), label);
    },
    
    /** make a link to a user's talkpage */
    talkLink: function(user, label) {
        return this.link(Titles.userTalkPage(user), label);
    },
    
    /** make a link to a user's contributions */
    contribsLink: function(user, label) {
        return this.link(Titles.specialPage("Contributions", user), label);
    },
    
    /** make a link to a lemma preventing inclusion */
    referenceLink: function(title) {
        var split   = Namespace.split(title);
        if (split.nsIndex === 6
        || split.nsIndex === 10
        || split.nsIndex === 14) {
            return this.link(":" + title, title);
        }
        else {
            return this.link(title);
        }
    },
    
    /** make a redirect link */
    redirect: function(title) {
        return "#redirect " + this.link(title); 
    },
    
    //------------------------------------------------------------------------------
    //## enclosing
    
    // enclosing
    template:   function()      { return this.TEMPLATE_ + Array.from(arguments).join(this._TEMPLATE_)   + this._TEMPLATE;   },
    link:       function()      { return this.LINK_     + Array.from(arguments).join(this._LINK_)       + this._LINK;       },
    web:        function()      { return this.WEB_      + Array.from(arguments).join(this._WEB_)        + this._WEB;        },
    h2:         function(text)  { return this.H2_ + text + this._H2; },
    
    //------------------------------------------------------------------------------
    //## composite
    
    SIGAPP:     " -- ~\~\~\~\n",
    DASH:       "--",   // "—" em dash U+2014 —
    
    //------------------------------------------------------------------------------
    //## tokens
    
    // enclosing
    TEMPLATE_:  "\{\{",
    _TEMPLATE_: "\|",
    _TEMPLATE:  "\}\}",
    LINK_:      "\[\[",
    _LINK_:     "\|",
    _LINK:      "\]\]",
    WEB_:       "\[",
    _WEB_:      " ",
    _WEB:       "\]",
    H2_:        "==",
    _H2:        "==",

    // simple
    SIG:        "~\~\~\~",
    LINE:       "----",

    // control chars
    STAR:       "*",
    HASH:       "#",
    COLON:      ":",
    SEMI:       ";",
    SP:         " ",
    LF:         "\n"//,
};

//======================================================================
//## lib/core/WikiLink.js 

/** the label is optional */
function WikiLink(title, label) {
    this.title  = title;
    this.label  = label;
}
WikiLink.prototype = {
    /** the title with spaces instead of underscores */
    normalizedTitle: function() {
        return title.replace(/_/g, " ");
    },
    
    /** the label if set, the normalized title if not */
    displayedLabel: function() {
        return label ? label : this.normalizedTitle();
    },
    
    /** omits the label if it equals the title */
    toPrettyString: function() {
        var title   = this.normalizedTitle();
        var label   = this.displayedLabel();
        return title !== label
                ? Markup.link(title, label)
                : Markup.link(title);
    },
    
    /** simple variant, omits the label if it's null */
    toString: function() {
        return this.label !== null
                ? Markup.link(this.title, this.label)
                : Markup.link(this.title);
    }//,
};

/** mangle all WikiLinks in a WikiText */
WikiLink.changeAll = function(wikiText, changeFunc) {
    var re  = /\[\[[ \t]*([^\]|]+?)[ \t]*(\|[ \t]*([^\]]*?)[ \t]*)?\]\]/g;
    return wikiText.replace(re, function($0, $1, $2, $3, pos, string) {
        var title   = $1;
        var second  = $2;
        var label   = $3;
        if (!second)    label = null;
        var orig    = new WikiLink(title, label);
        var changed = changeFunc(orig);
        return changed !== null ? changed.toString() : $0;
    });
}

/** returns an Array of all WikiLinks contained in a WikiText */
WikiLink.parseAll = function(wikiText) {
    var out = [];
    this.changeAll(wikiText, function(wikiLink) {
        out.push(wikiLink);
        return null;
    });
    return out;
}

//======================================================================
//## lib/core/Config.js 

/** helper for wiki-local settings */
var Config = {
    /** configure a configuration for the current wiki domain or language */
    patch: function(cfg) {
        var language    = cfg[Wiki.language];
        var domain      = cfg[Wiki.domain];
        if (language)   Object.assign(cfg, language);
        if (domain)     Object.assign(cfg, domain);
    }//,
};

//======================================================================
//## lib/core/IP.js 

// @see mw.util.isIPv4Address
// @see mw.util.isIPv6Address
var IP = {
    /** matches IPv4-like strings */
    v4RE: /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,

    /** whether the string denotes an IPv4-address */
    isV4: function(s) {
        var m   = this.v4RE.exec(s);
        if (!m) return false;
        for (var i=1; i<=4; i++) {
            var byt = parseInt(m[i]);
            if (byt < 0 || byt > 255)   return false;
        }
        return true;
    }//,
};

//======================================================================
//## lib/core/Afterwards.js 

/** creates functions to be used after user actions */
var Afterwards = {
    /** returns a function reloading the page if changed and current page are the same */
    maybeReloadFunc: function(title) {
        return this.maybeReload.bind(this, title);
    },
    
    /** returns a function loading the history of a page if changed and current page are the same */
    maybeHistoryFunc: function(title) {
        return this.maybeHistory.bind(this, title);
    },
    
    //------------------------------------------------------------------------------
    
    /** reload a page if it is the current page */
    maybeReload: function(title) {
        if (title !== Page.title)   return;
        window.location.href    = Wiki.readURL(title);
    },
    
    /** show a pages history if it is the current page */
    maybeHistory: function(title) {
        if (title !== Page.title)   return;
        window.location.href    = Wiki.encodeURL({
            title:  title,
            action: "history"//,
        });
    }//,
};

//======================================================================
//## lib/ui/closeButton.js 

/** creates a close button calling a function on click */
function closeButton(closeFunc) {
    var button  = document.createElement("input");
    button.type         = "submit";
    button.value        = "x";
    button.className    = "closeButton";
    if (closeFunc)  button.onclick  = closeFunc;
    return button;
}

//======================================================================
//## lib/ui/FoldButton.js 

/** FoldButton class */
function FoldButton(initiallyOpen, reactor) {
    var self        = this;
    this.button     = document.createElement("span");
    this.button.className   = "folding-button";
    this.button.onclick     = function() { self.flip(); }
    this.open       = initiallyOpen ? true : false;
    this.reactor    = reactor;
    this.display();
}
FoldButton.prototype = {
    /** flip the state and tell the reactor */
    flip: function() {
        this.change(!this.open);
        return this;
    },

    /** change state and tell the reactor when changed */
    change: function(open) {
        if (open === this.open) return;
        this.open   = open;
        if (this.reactor)   this.reactor(open);
        this.display();
        return this;
    },

    /** change the displayed state */
    display: function() {
        this.button.innerHTML   = this.open
                                ? "▼"
                                : "►";
        return this;
    }//,
};

//======================================================================
//## lib/ui/SwitchBoard.js 

/** contains a number of on/off-switches */
function SwitchBoard() {
    this.knobs  = [];
    this.board  = document.createElement("span");
    this.board.className    = "switch-board";

    // public
    this.component  = this.board;
}
SwitchBoard.prototype = {
    /** add a knob and set its className */
    add: function(knob) {
        jsutil.DOM.addClass(knob, "switch-knob");
        jsutil.DOM.addClass(knob, "switch-off");
        this.knobs.push(knob);
        this.board.appendChild(knob);
    },

    /** selects a single knob */
    select: function(knob) {
        this.changeAll(false);
        this.change(knob, true);
    },

    /** changes selection state of one knob */
    change: function(knob, selected) {
        if (selected)   jsutil.DOM.replaceClass(knob, "switch-off", "switch-on");
        else            jsutil.DOM.replaceClass(knob, "switch-on",      "switch-off");
    },

    /** changes selection state of all knobs */
    changeAll: function(selected) {
        for (var i=0; i<this.knobs.length; i++) {
            this.change(this.knobs[i], selected);
        }
    }//,
};

//======================================================================
//## lib/ui/Floater.js 

/** a Floater is a small area floating over the document */
function Floater(id, limited) {
    this.limited    = limited;
    
    // public
    this.canvas = document.createElement("div");
    this.canvas.id          = id;
    this.canvas.className   = "floater";
    
    // shortcut
    this.style  = this.canvas.style;
    
    // attaching to a node below body leads to clipping: overflow:visible maybe?
    //this.source.appendChild(this.canvas);
    document.body.appendChild(this.canvas);
    Floater.instances.push(this);
    this.style.zIndex   = this.minimumZ + Floater.instances.length - 1;
}
Floater.prototype = {
    /** z-index for the lowest Floater */
    minimumZ: 1000,

    /** removes this Floater from the view */
    destroy: function() {
        Floater.instances.remove(this);
        // TODO: change all other fields like it was removed
        document.body.removeChild(this.canvas);
    },
    
    /** locates the div near a mouse position */
    locate: function(pos) {
        // helps with https://bugzilla.mozilla.org/show_bug.cgi?id=324819
        // display is necessary for position, visibility is not
        this.style.display  = "block";
        if (this.limited)   pos = this.limit(pos);
        jsutil.DOM.moveElement(this.canvas, pos);
    },
    
    /** limits canvas position to the window */
    limit: function(pos) {
        var min     = jsutil.DOM.minPos();
        var max     = jsutil.DOM.maxPos();
        var size    = jsutil.DOM.elementSize(this.canvas);
        
        // HACK: why does the menu go too far to the right without this?
        size.x  += 16;
        pos = { x: pos.x, y: pos.y };
        
        if (pos.x < min.x)          pos.x   = min.x;
        if (pos.y < min.y)          pos.y   = min.y;
        if (pos.x + size.x > max.x) pos.x = max.x - size.x;
        if (pos.y + size.y > max.y) pos.y = max.y - size.y;
        return pos;
    },
    
    /** returns the current location */
    location: function() {
        // display is necessary for position, visibility is not
        this.style.display  = "block";
        return jsutil.DOM.elementPos(this.canvas);
    },
    
    /** displays the div */
    show: function() {
        this.style.display      = "block";
        this.style.visibility   = "visible";
    },
    
    /** hides the div */
    hide: function() {
        this.style.display      = "none";
        this.style.visibility   = "hidden";
    },
    
    /** raises the div above all other Floaters */
    raise: function() {
        var all = Floater.instances;
        var idx = all.indexOf(this);
        if (idx === -1) return;
        all.splice(idx, 1);
        all.push(this);
        for (var i=idx; i<all.length; i++) {
            all[i].style.zIndex = i + this.minimumZ;
        }
    },
    
    /** lower the div below all other Floaters */
    lower: function() {
        var all = Floater.instances;
        var idx = all.indexOf(this);
        if (idx === -1) return;
        all.splice(idx, 1);
        all.unshift(this);
        for (var i=idx; i>= 0; i++) {
            all[i].style.zIndex = i + this.minimumZ;
        }
    }//,
};

/** all instances z-ordered starting with the lowest */
Floater.instances   = [];

//======================================================================
//## lib/ui/PopupMenu.js 

/** a PopupMenu display a number of items and call a selectFunc when one of the items is selected */ 
function PopupMenu(selectFunc) {
    this.selectFunc = selectFunc;
    this.floater    = new Floater(null, true);
    this.canvas     = this.floater.canvas;
    jsutil.DOM.addClass(this.canvas, "popup-menu-window");
    this.canvas.onmouseup   = this.maybeSelectItem.bind(this);
}
PopupMenu.prototype = {
    /** removes this menu */
    destroy: function() {
        this.canvas.onmouseup   = null;
        this.floater.destroy();
    },
    
    /** opens at a given position */
    showAt: function(pos) {
        this.floater.locate(pos);
        this.floater.raise();
        this.floater.show();
    },
    
    /** closes the menu */
    hide: function() {
        this.floater.hide();
    },
    
    /** adds an item, its userdata will be supplied to the selectFunc */
    item: function(label, userdata) {
        var item    = document.createElement("div");
        item.className      = "popup-menu-item";
        item.textContent    = label;
        item.userdata       = userdata;
        this.canvas.appendChild(item);
    },

    /** adds a separator */
    separator: function() {
        var separator   = document.createElement("hr");
        separator.className = "popup-menu-separator";
        this.canvas.appendChild(separator);
    },

    /** calls the selectFunc with the userData of the selected item */
    maybeSelectItem: function(ev) {
        var target  = ev.target;
        for (;;) {
            if (jsutil.DOM.hasClass(target, "popup-menu-item")) {
                if (this.selectFunc) {
                    this.selectFunc(target.userdata);
                }
                return;
            }
            target  = target.parentNode;
            if (!target)    return;
        }
    }//,
};

//======================================================================
//## lib/ui/PopupSource.js 

/** makes a source open a Floater as context-menu */
function PopupSource(source, menu) {
    this.source = source;
    this.menu   = menu;
    jsutil.DOM.addClass(source, "popup-source");
    source.oncontextmenu    = this.contextMenu.bind(this);
    this.boundMouseUp       = this.mouseUp.bind(this);
    document.addEventListener("mouseup", this.boundMouseUp, false);
}
PopupSource.prototype = {
    mouseupCloseDelay: 250,
    
    /** removes all listeners */
    destroy: function() {
        jsutil.DOM.removeClass(this.source, "popup-source");
        this.source.oncontextmenu   = null;
        document.removeEventListener("mouseup", this.boundMouseUp, false);
    },
    
    /** opens the Floater near the mouse cursor */
    contextMenu: function(ev) {
        if (ev.target !== this.source)  return;
        
        var mouse   = jsutil.DOM.mousePos(ev);
        // so the document does not get a mouseup shortly after
        mouse.x ++;
        this.menu.showAt(mouse);

        // delay closing so the popup stays open after a short klick
        this.abortable  = false;
        var self        = this;
        window.setTimeout(
                function() { self.abortable = true; }, 
                this.mouseupCloseDelay);
        
        // old-style, stop propagation
        return false;
    },
    
    /** closes the Floater except within a short time after opening */
    mouseUp: function(ev) {
        if (this.abortable) {
            this.menu.hide();
        }
    }//,
};

//======================================================================
//## lib/ui/Links.js 

/** creates links to action functions and pages */
var Links = {
    /**
     * create an action link which
     * - onclick        queries a text and
     * - oncontextmenu  opens a popup with default texts
     * and calls the single-argument selectFunc with it.
     *
     * promptLabel may be null to indicate no prompt is
     * wanted, the selecteFunc is called with null in this case.
     * 
     * groups is an Array of Arrays of preset reason Strings.
     * a separator is placed between rows. null is allowed to
     * disable the popup.
     */
    promptPopupLink: function(label, promptLabel, groups, selectFunc) {
        var link    = promptLabel !== null
                    ? this.promptLink(label, promptLabel, selectFunc)
                    : this.functionLink(label, selectFunc.bind(this, null));
        if (groups) this.popupOnLink(link, groups, selectFunc);
        return link;
    },
    
    /** add a popup calling a function to an existing link */
    popupOnLink: function(link, groups, popupFunc) {
        var popup   = new PopupMenu(popupFunc);
        // setup groups of items
        for (var i=0; i<groups.length; i++) {
            var group   = groups[i];
            if (i !== 0)    popup.separator();
            for (var j=0; j<group.length; j++) {
                var preset  = group[j];
                popup.item(preset, preset);
            }
        }
        new PopupSource(link, popup);
    },

    /** create an action link which onclick queries a text and calls a function with it */
    promptLink: function(label, promptLabel, okFunc) {
        return this.functionLink(label, function() {
            var reason  = prompt(promptLabel);
            if (reason !== null)    okFunc(reason);
        });
    },

    /** create an action link calling a function on click */
    functionLink: function(label, clickFunc) {
        var a   = document.createElement("a");
        a.className     = "link-function";
        a.onclick       = clickFunc;
        a.textContent   = label;
        return a;
    },

    /** create a link to a readURL */
    readLink: function(label, title,  args) {
        return this.urlLink(label, Wiki.readURL(title, args));
    },

    /** create a link to an actionURL */
    pageLink: function(label, args) {
        return this.urlLink(label, Wiki.encodeURL(args));
    },

    /** create a link to an URL within the current list item */
    urlLink: function(label, url) {
        var a   = document.createElement("a");
        a.href          = url;
        a.textContent   = label;
        return a;
    }//,
};

//======================================================================
//## lib/ui/ProgressArea.js 

/** uses a ProgressArea to display ajax progress */
function ProgressArea() {
    var close   = closeButton(this.destroy.bind(this));

    var headerDiv   = document.createElement("div");
    headerDiv.className = "progress-header";

    var bodyDiv     = document.createElement("div");
    bodyDiv.className   = "progress-body";

    var outerDiv    = document.createElement("div");
    outerDiv.className  = "progress-area";
    outerDiv.appendChild(close);
    outerDiv.appendChild(headerDiv);
    outerDiv.appendChild(bodyDiv);

    // the mainDiv is a singleton
    var mainDiv     = jsutil.DOM.get('progress-global');
    if (mainDiv === null) {
        mainDiv = document.createElement("div");
        mainDiv.id          = 'progress-global';
        mainDiv.className   = "progress-global";
        jsutil.DOM.insertBefore(jsutil.DOM.get('bodyContent'), mainDiv);
    }
    mainDiv.appendChild(outerDiv);

    this.headerDiv  = headerDiv;
    this.bodyDiv    = bodyDiv;
    this.outerDiv   = outerDiv;

    this.timeout    = null;
}
ProgressArea.prototype = {
    /** display a header text */
    header: function(content) {
        this.unfade();
        jsutil.DOM.removeChildren(this.headerDiv);
        jsutil.DOM.insertEnd(this.headerDiv, document.createTextNode(content));
    },

    /** display a body text */
    body: function(content) {
        this.unfade();
        jsutil.DOM.removeChildren(this.bodyDiv);
        jsutil.DOM.insertEnd(this.bodyDiv, document.createTextNode(content));
    },

    /** destructor, called by fade */
    destroy: function() {
        jsutil.DOM.removeNode(this.outerDiv);
    },

    /** fade out */
    fade: function() {
        this.timeout    = setTimeout(this.destroy.bind(this), ProgressArea.cfg.fadeTime);
    },

    /** inihibit fade */
    unfade: function() {
        if (this.timeout !== null) {
            clearTimeout(this.timeout);
            this.timeout    = null;
        }
    }//,
};
ProgressArea.cfg = {
    fadeTime:   750,    // fade delay in millis
};

//======================================================================
//## lib/ui/FeedbackLink.js 

/** 
 * implements the Feedback interface defined in Actions.js
 * change an ActionLink's CSS-class link-running
 */
function FeedbackLink(link) {
    this.link   = link;
}
FeedbackLink.prototype = {
    job:        function(s) { },
    work:       function(s) { jsutil.DOM.addClass(this.link, "link-running");       },
    success:    function(s) { jsutil.DOM.removeClass(this.link, "link-running");    },
    failure:    function(s) { jsutil.DOM.replaceClass(this.link, "link-running", "link-failed" ); }
};

//======================================================================
//## lib/ui/FeedbackArea.js 

/** 
 * implements the Feedback interface defined in Actions.js
 * delegates to a ProgressArea
 */
function FeedbackArea() {
    this.area   = new ProgressArea();
}
FeedbackArea.prototype = {
    job:        function(s) { this.area.header(s);  },
    work:       function(s) { this.area.body(s);    },
    success:    function(s) { this.area.body(s);    this.area.fade();   },
    failure:    function(s) { this.area.body(s);    },  // #body unfades
};

//======================================================================
//## lib/ui/SideBar.js 

/** encapsulates column-one */
var SideBar = {
    /**
     * change labels of action links
     * root is a common parent of all items, f.e. document
     * labels is a Map from id to label
     */
    labelItems: function(labels) {
         for (var id in labels) {
             var el = document.getElementById(id);
             if (!el)   continue;
             var a  = el.getElementsByTagName("a")[0];
             if (!a)    continue;
             a.textContent  = labels[id];
         }
    },
    
    //------------------------------------------------------------------------------

    /** the portlets remembered in addSimplePortlet/addComplexPortlet and displayed in showPortlets */
    preparedPortlets: [],
    
    /** add a portlet from an element */
    addSimplePortlet: function(id, title, content) {
        this.preparedPortlets.push(
                this.createPortlet(id, title, content));
    },

    /**
     * render an array of arrays of links.
     * the outer array may contains strings to steal list items
     * null items in the outer array or inner are legal and skipped
     */
    addComplexPortlet: function(id, title, labels, rows) {
        this.addSimplePortlet(id, title,
                this.renderPortletContent(rows, labels));
    },
    
    /** 
     * render a complex portlet's content element from an Array of Arrays
     * containing elements or ids of elements to steal
     */
    renderPortletContent: function(rows, labels) {
        // remove nulls, steal nodes
        var newRows = [];
        for (var i=0; i<rows.length; i++) {
            var cells   = rows[i];
            if (!cells) continue;
            var newCells    = [];
            for (var j=0; j<cells.length; j++) {
                var cell    = cells[j];
                if (!cell)  continue;
                if (cell.constructor === String) {
                    var element = jsutil.DOM.get(cell);
                    if (!element)   continue;
                    var label   = labels[cell];
                    cell    = element.firstChild.cloneNode(true);
                    if (label) {
                        cell.textContent    = label;
                    }
                }
                newCells.push(cell);
            }
            if (newCells.length === 0)  continue;
            newRows.push(newCells);
        }
        //if (newRows.length === 0) return null;
        
        // compile into ul/li/node
        var ul  = document.createElement("ul");
        for (var i=0; i<newRows.length; i++) {
            var newCells    = newRows[i];
            var li  = document.createElement("li");
            for (var j=0; j<newCells.length; j++) {
                if (j !== 0) {
                    li.appendChild(document.createTextNode(" "));
                }
                var newCell = newCells[j];
                li.appendChild(newCell);
            }
            ul.appendChild(li);
        }
        
        return ul;
    },
    
    /** create a portlet div */
    createPortlet: function(id, title, content) {
        var header  = document.createElement("h3");
        header.textContent  = title;
    
        var body    = document.createElement("div");
        body.className  = "pBody";
        body.appendChild(content);
        
        var portlet = document.createElement("div");
        portlet.id          = id;
        portlet.className   = "portlet";
        portlet.appendChild(header);
        portlet.appendChild(body);
        return portlet;
    },

    /** display the portlets created before and remove older ones with the same id */
    showPortlets: function() {
        var columnOne   = jsutil.DOM.get('column-one');
        for (var i=0; i<this.preparedPortlets.length; i++) {
            var portlet     = this.preparedPortlets[i];
            var replaces    = jsutil.DOM.get(portlet.id);
            if (replaces)   jsutil.DOM.removeNode(replaces);
            columnOne.appendChild(portlet);
        }
        // HACK for speedup, hidden in sideBar.css
        columnOne.style.visibility  = "visible";
    },
    
    //------------------------------------------------------------------------------

    /** adds a div with the site name at the top of the sidebar */
    insertSiteName: function() {
        var heading = jsutil.DOM.fetch('p-search', "h3", null, 0);
        jsutil.DOM.removeChildren(heading);
        
        var name    = this.siteName();
        if (name == null)   return;
        
        var a           = document.createElement("a");
        a.id            = "siteName";
        a.textContent   = name;
        a.href          = Wiki.site;
        
        heading.appendChild(a);
    },
    
    /** find the name of the current wiki */
    siteName: function() {
        var links   = document.getElementsByTagName("link");
        for (var i=0; i<links.length; i++) {
            var link    = links[i];
            if (link.rel == "search")   return link.title;
        }
        return null;
    }//,
};

//======================================================================
//## app/extension/ActionHistory.js 

/** helper for action=history */
var ActionHistory = {
    /** onload initializer */
    init: function() {
        if (Page.params["action"] !== "history")    return;
        this.addLinks();
    },

    //------------------------------------------------------------------------------
    //## private

    /** additional links for every version in a page history */
    addLinks: function() {
        var lis = jsutil.DOM.fetch('pagehistory', "li");
        if (!lis)   return;
        for (var i=0; i<lis.length; i++) {
            var li  = lis[i];
            this.editAndRestore(li);
            this.immediatize(li);
        }
    },
    
    /** add edit and restore links to an item */
    editAndRestore: function(li) {
        var diffInput   = jsutil.DOM.fetch(li, "input", null, 1);
        if (!diffInput) return;

        // gather data
        var histSpan    = jsutil.DOM.fetch(li, "span", "history-user", 0);
        var histA       = jsutil.DOM.fetch(histSpan, "a", null, 0);
        //  (Username or IP removed)
        if (!histA) return;
        
        var dateA       = jsutil.DOM.nextElement(diffInput, "a");
        if (!dateA) return;
        
        var oldid       = diffInput.value;
        var user        = histA.textContent;
        var date        = dateA.textContent;
        
        var msg = ActionHistory.msg;

        // add edit and restore version link
        function done() { window.location.reload(true); }
        var restore = FastRestore.linkRestore(Page.title, oldid, user, date, done);
        var edit    = Links.pageLink(msg.edit, {
            title:  Page.title,
            oldid:  oldid,
            action: "edit"//,
        });
        jsutil.DOM.insertBeforeMany(dateA,
            jsutil.DOM.textAsNodeMany([ 
                " [", edit, "] ",
                " [", restore, "] "
            ])
        );
    },

    /** extend undo and rollback links */
    immediatize: function(li) {
        // TODO unify with ActionDiff
        var undoSpan    = jsutil.DOM.fetch(li, "span", 'mw-history-undo', 0);
        if (undoSpan) {
            var undoA   = jsutil.DOM.fetch(undoSpan, "a", null, 0);
            FastUndo.patchLink(undoA,
                     Afterwards.maybeHistoryFunc(Page.title));
        }  
        // TODO add a revert-button for users without rollback
        
        var rollbackSpan    = jsutil.DOM.fetch(li, "span", 'mw-rollback-link', 0);
        if (rollbackSpan) {
            var rollbackA   = jsutil.DOM.fetch(rollbackSpan, "a", null, 0);
            FastRollback.patchLink(rollbackA,
                    Afterwards.maybeHistoryFunc(Page.title));
        }
    }
};
ActionHistory.msg = {
    edit:   "edit"//,
};

//======================================================================
//## app/extension/ActionDiff.js 

/** revert in the background for action=diff */
var ActionDiff = {
    /** onload initializer */
    init: function() {
        if (!Page.params["diff"])   return;
        this.fastUndo();
        this.fastRevert();
        this.addLinks("diff-ntitle");
        this.addLinks("diff-otitle");
    },

    //------------------------------------------------------------------------------
    //## private
    
    hasRollback: false,

    /** extend undo links */
    fastUndo: function() {
        var newVersion  = jsutil.DOM.get('mw-diff-ntitle1');
        if (!newVersion)    return;
        var as  = jsutil.DOM.fetch(newVersion, "a");
        if (!as.length) return;
        var a   = as[as.length-1];  // the last link
        FastUndo.patchLink(a, 
                Afterwards.maybeHistoryFunc(Page.title));
    },
    
    /** extend rollback links */
    fastRevert: function() {
        var newVersion  = jsutil.DOM.get('mw-diff-ntitle2');
        if (!newVersion)    return;
        var span    = jsutil.DOM.fetch(newVersion, "span", "mw-rollback-link", 0);
        if (!span)  return;
        var a       = jsutil.DOM.fetch(span, "a", null, 0);
        FastRollback.patchLink(a,
                Afterwards.maybeHistoryFunc(Page.title));
        this.hasRollback    = true;
    },

    /** extends one of the two sides */
    addLinks: function(tdClassName) {
        // get container
        var td  = jsutil.DOM.fetch(document, "td", tdClassName, 0);
        if (!td)    return;
        
        // get lines
        var divs    = td.getElementsByTagName("div");
        
        // extract revision info
        var revisionA   = jsutil.DOM.fetch(divs[0], "a", null, 0);
        if (!revisionA) return;
        
        var params  = Wiki.decodeURL(revisionA.href);
        var oldid   = params.oldid;
        var dateP   = ActionDiff.cfg.versionExtractRE.exec(revisionA.textContent);
        var date    = dateP ? dateP[1] : null;
        
        // extract user info
        var userA   = jsutil.DOM.fetch(divs[1], "a", null, 0);
        // (Username or IP removed)
        if (!userA) return; 
        var user    = userA.textContent;
        
        var done    = Afterwards.maybeHistoryFunc(Page.title);
        
        // if an oldid exists: add a restore revision link
        var restorable  = params.oldid;
        if (restorable) {
            var restore = FastRestore.linkRestore(Page.title, oldid, user, date, done);
            var targetA = jsutil.DOM.fetch(divs[0], "a", null, 1);
            if (targetA) {
                jsutil.DOM.insertBeforeMany(targetA,
                    jsutil.DOM.textAsNodeMany([ restore, ") ("])
                );
            }
        }
        
        // note: some users have rollback, others don't
        if (!this.hasRollback && divs.length > 3) {
            // latest revision: add a revert link
            var div3 = divs[3];
            var latest  = div3.id === "mw-diff-ntitle4" && ( 
                        div3.innerHTML === " " ||  // firefox 
                        div3.innerHTML === "\u00a0" );  // safari
            if (latest) {
                var revert  = UserRevert.linkRevert(Page.title, user, done);
                jsutil.DOM.insertEndMany(divs[1],
                    jsutil.DOM.textAsNodeMany([ " [", revert, "]" ])
                );
            }
        }
    }//,
};
ActionDiff.cfg = {
    // TODO: hardcoded lang_de OR lang_en
    versionExtractRE:   /(?:Version vom|Revision as of) (.*)/ //,
};

//======================================================================
//## app/extension/SpecialAny.js 

/** dispatcher for SpecialPages */
var SpecialAny = {
    /** dispatches calls to Special* objects */
    init: function() {
        var name    = Page.whichSpecial();
        if (!name)      return;

        var feature = window["Special" + name];
        if (feature && feature.init) {
            feature.init();
        }

        this.mangleForm(name);
    },
    
    mangleForm: function(specialPage) {
        // if there is only one form, it's the searchform
        if (document.forms.length < 2)  return;
        var form    = document.forms[0];
        if (!form)  return;
        
        // only pages listed below are mangled
        var elementNames    = SpecialAny.cfg.autoSubmitElements[specialPage];
        if (!elementNames)  return;
        this.autoSubmit(form, elementNames);
        
        // HACK: in Watchlist the submit-button is necessary
        if (specialPage === "Watchlist" 
        && Page.params["action"])
                return;
        
        // HACK: in the Undelete sometimes the buttons are necessary. or at least a restore-parameter
        if (specialPage === "Undelete") {
            if (Page.params["timestamp"] || Page.params["action"] === "submit")
                    return;
            form.action += "&restore=yes";
        }
        
        this.removeButtons(form);
    },

    /** adds an onchange handler to elements in a form submitting the form */
    autoSubmit: function(form, elementNames) {
        function change() { form.submit(); }
        var elements    = form.elements;
        for (var i=0; i<elementNames.length; i++) {
            var elementName = elementNames[i];
            var element     = elements[elementName];
            if (!element)   continue;
            // radioButtons return an array, not a single element,
            // but only when accessed by name (!)
            if (element.type) {
                element.onchange    = change;
            }
            
}
    },
    
    /** removes submit button(s) */
    removeButtons: function(form) {
        var elements    = form.elements;
        for (var i=0; i<elements.length; i++) {
            var element = elements[i];
            if (element.type !== "submit" 
            && element.type !== "reset")    continue;
            element.style.display   = "none";
        }
    }//,
};
SpecialAny.cfg = {
    /**
     * maps Specialpage names to the autosubmitting form elements.
     * only SpecialPages listed here have their submit-buttons hidden.
     */
    autoSubmitElements: {
        
AbuseFilter:    [   // "deletedfilters", 
                            "mw-abusefilter-deletedfilters-show", 
                            "mw-abusefilter-deletedfilters-hide", 
                            "mw-abusefilter-deletedfilters-only",
                            "hidedisabled", "limit" ],
        StablePages:    [ "namespace"               ],
        GlobalUsers:    [ "group",  "username"      ],
        Allpages:       [ "namespace", "nsfrom"     ],
        Contributions:  [ "namespace", "month"      ],              // contribs is a bit useless    
        Ipblocklist:    [ "title",                                  // default action
                          "wpUnblockAddress",   "wpUnblockReason"   // action=unblock
                        ],
        LinkSearch:     [ "title"                   ],
        Listusers:      [ "group", "username"       ],
        Log:            [ "type", "user", "page", 
                            "year",     "month"     ],
        Newimages:      [ "wpIlMatch"               ],
        Newpages:       [ "namespace", "username"   ],
        Prefixindex:    [ "namespace", "nsfrom"     ],
        Recentchanges:  [ "namespace", "invert"     ],
        Watchlist:      [ "namespace"               ],
        Whatlinkshere:  [ "namespace"               ],
        Booksources:    [ "isbn"                    ],
        CategoryTree:   [ "mode",   "target"        ],
        Cite:           [ "page"                    ],
        Filepath:       [ "file"                    ],
        Listfiles:      [ "limit"                   ],
        MIMEsearch:     [ "mime"                    ],
        Search:         [ "lsearchbox"              ],
        Undelete:       [ 
],
        DeletedContributions:
                        [ "namespace",  "target"    ],
        ReviewedPages:  [ "level"                   ],
        Userrights:     [ "user"                    ],
        Recentchangeslinked:
                        [ "recentchangeslinked-target", "showlinkedto" ]//,
    }//,
};

//======================================================================
//## app/extension/SpecialBlockIP.js 

/** extends Special:BlockIP */
var SpecialBlockIP = {
    /** onload initializer */
    init: function() {
        this.presetBlockIP();
    },

    //------------------------------------------------------------------------------
    //## private

    /** fill in default values into the blockip form */
    presetBlockIP: function() {
        // if there is only one form, it's the searchform
        if (document.forms.length < 2)  return;
        var form    = document.forms[0];    // was blockip
        if (!form)  return; // action=success

        var def = SpecialBlockIP.cfg.defaults;
        form.elements["mw-input-wpExpiry"].value        = "other";
        form.elements["mw-input-wpExpiry-other"].value  = def.expiry;
        form.elements["mw-input-wpReason-other"].value  = def.reason;
        form.elements["mw-input-wpReason-other"].select();
        form.elements["mw-input-wpReason-other"].focus();
    }//,
};
SpecialBlockIP.cfg = {
    defaults: {
        expiry: "2 hours",
        reason: "vandalismus"//,
    }//,
};

//======================================================================
//## app/extension/SpecialContributions.js 

/** revert in the background for Special:Contributions */
var SpecialContributions = {
    /** onload initializer */
    init: function() {
        this.fastRevert();
    },

    //------------------------------------------------------------------------------
    //## private

    /** extend rollback links */
    fastRevert: function() {
        var warning = jsutil.DOM.fetch('bodyContent', "div", "mw-warning-with-logexcerpt", 0);
        
        var ul  = jsutil.DOM.fetch('bodyContent', "ul", null, warning ? 1 : 0);
        if (ul === null)    return;

        function changeLink(link) { 
            link.textContent = SpecialContributions.msg.reverted;
        }
        
        var all = [];
        var spans   = jsutil.DOM.fetch(ul, "span", "mw-rollback-link");
        for (var i=0; i<spans.length; i++) {
            var span    = spans[i];
            var a       = jsutil.DOM.fetch(span, "a", null, 0);
            var p       = FastRollback.patchLink(a, changeLink);
            all.push(p);
        }
        
}//,
};
SpecialContributions.msg = {
    reverted:   "zurückgesetzt",
};

//======================================================================
//## app/extension/SpecialNewpages.js 

/** extends Special: */
var SpecialNewpages = {
    /** onload initializer */
    init: function() {
        this.displayInline();
    },

    //------------------------------------------------------------------------------
    //## private
    
    /** extend Special: with the content of the articles */
    displayInline: function() {
        var openCount = 0;

        /** parse one list item, then add folding and the inline view to it */
        function extendItem(li) {
            // fetch data
            var a       = li.getElementsByTagName("a")[0];
            var title   = a.title;

            var byteStr = li.innerHTML
                    .replace(SpecialNewpages.cfg.bytesExtractRE, "$1")
                    .replace(SpecialNewpages.cfg.bytesStripRE,   "");
            var bytes   = parseInt(byteStr);

            // make header
            var header  =  document.createElement("div");
            header.className    = "folding-header";
            header.innerHTML    = li.innerHTML;

            // make body
            var body    = document.createElement("div");
            body.className      = "folding-body";

            // a FoldButton for the header
            var foldButton  = new FoldButton(true, function(open) {
                body.style.display  = open ? "" : "none";
                if (open && foldButton.needsLoad) {
                    loadContent(li);
                    foldButton.needsLoad    = false;
                }
            });
            foldButton.needsLoad    = false;
            jsutil.DOM.insertBegin(header, foldButton.button);

            // add action links
            jsutil.DOM.insertBegin(header, Google.link(title));
            jsutil.DOM.insertBegin(header, UserBookmarks.linkMark(title));
            var templateTools   = TemplatePage.bankAllPage(title);
            if (templateTools)  jsutil.DOM.insertBeginMany(header, templateTools);
            jsutil.DOM.insertBegin(header, FastDelete.linkDeletePopup(title));
            // change listitem
            li.pageTitle    = title;
            li.contentBytes = bytes;
            li.headerDiv    = header;
            li.bodyDiv      = body;
            li.className    = "folding-container";
            li.innerHTML    = "";
            li.appendChild(header);
            li.appendChild(body);

            if (li.contentBytes <= SpecialNewpages.cfg.sizeLimit
            && openCount < SpecialNewpages.cfg.maxArticles) {
                loadContent(li);
                openCount++;
            }
            else {
                foldButton.change(false);
                foldButton.needsLoad    = true;
            }
        }

        /** load the article content and display it inline */
        function loadContent(li) {
            li.bodyDiv.textContent  = SpecialNewpages.msg.loading;
            jsutil.Ajax.call({
                url:            Wiki.readURL(li.pageTitle, { redirect: "no" }),
                successFunc:    function(source) {
                    // uses the monobook start content marker
                    // firefox, safari: [^] works, div is lower case
                    // opera:           [^] does not work, div is upper case
                    var extractRE   = /<!-- start content -->([\s\S]*)<(div|DIV) class="printfooter">/; 
                    var content     = extractRE.exec(source.responseText);
                    if (!content)   throw "could not extract article content";
                    li.bodyDiv.innerHTML    = content[1] + '<div class="visualClear" />';
                    // <div class="noarticletext">
                }
            });
        }

        // find article list
        var ul  = jsutil.DOM.fetch('bodyContent', "ul", null, 0);
        if (!ul)    return;
        ul.className    = "SpecialNewpages";

        // find article list items
        var lis = jsutil.DOM.fetch(ul, "li");
        for (var i=0; i<lis.length; i++) {
            extendItem(lis[i], i);
        }
    }//,
};
SpecialNewpages.cfg = {
    maxArticles:    100,
    sizeLimit:      2048,

    // TODO: hardcoded lang_de OR lang_en
    bytesExtractRE:     /.*\[([0-9.,]+) [Bb]ytes\].*/,
    bytesStripRE:       /[.,]/g//,
};
SpecialNewpages.msg = {
    loading:    "lade seite.."//,
};

//======================================================================
//## app/extension/SpecialSpecialPages.js 

/** extends Special:SpecialPages */
var SpecialSpecialPages = {
    /** onload initializer */
    init: function() {
        this.extendLinks();
    },

    //------------------------------------------------------------------------------
    //## private

    /** make a sorted tables from the links */
    extendLinks: function() {
        var tables  = jsutil.DOM.fetch('bodyContent', "table", "mw-specialpages-table");
        for (var i=0; i<tables.length; i++) {
            var group   = tables[i];
            this.extendGroup(group);
        }
    },
    
    /** make a sorted table from the links of one group */
    extendGroup: function(group) {
        var as  = jsutil.DOM.fetch(group, "a");
        for (var i=0; i<as.length; i++) {
            var a       = as[i];
            var name    = Namespace.scan(-1, a.title);
            var descr   = a.textContent;
            
            a.title         = descr;
            a.textContent   = name;
            
            var hint            = document.createElement("span");
            hint.className      = "specialSpecialPagesHint";
            jsutil.DOM.insertAfter(a, hint);
            
            var specialNames    = Special.pageNames(name);
            if (!specialNames) {
                hint.textContent    = "(missing)";
                continue;
            }
            
            var canonical       = specialNames.canonicalName;
            if (canonical === name) {
                hint.textContent    = "(canonical)";
                continue;
            }
            
            hint.textContent    = canonical;
        }
        
}//,
};

//======================================================================
//## app/extension/SpecialUndelete.js 

/** extends Special:Undelete */
var SpecialUndelete = {
    /** onload initializer */
    init: function() {
        this.toggleAll();
    },

    //------------------------------------------------------------------------------
    //## private

    /** add an invert button for all checkboxes */
    toggleAll: function() {
        var form    = document.forms[0];
        if (!form)  return;

        var button  = Links.functionLink(SpecialUndelete.msg.invert, function() {
            var els = form.elements;
            for (var i=0; i<els.length; i++) {
                var el  = els[i];
                if (el.type !== "checkbox") continue;
                el.checked  = !el.checked;
            }
        });

        var target  = jsutil.DOM.fetch(form, "ul", null, 2);
        // no list if there is only one deleted version
        if (target === null)    return;
        target.parentNode.insertBefore(button, target);
    }//,
};
SpecialUndelete.msg = {
    invert: "Invertieren"//,
};

//======================================================================
//## app/extension/SpecialRecentchanges.js 

/** extensions for Special:Recentchanges */
var SpecialRecentchanges = {
    /** onload initializer */
    init: function() {
        FilteredEditList.filterLinks("FilteredEditList_SpecialRecentchanges");
    }//,
};

//======================================================================
//## app/extension/SpecialRecentchangeslinked.js 

/** extensions for Special:Recentchangeslinked */
var SpecialRecentchangeslinked = {
    /** onload initializer */
    init: function() {
        FilteredEditList.filterLinks("FilteredEditList_SpecialRecentchangeslinked");
    }//,
};

//======================================================================
//## app/extension/SpecialWatchlist.js 

/** extensions for Special:Watchlist */
var SpecialWatchlist = {
    /** onload initializer */
    init: function() {
        var action  = Page.params["action"];
        if (!action) {
            FilteredEditList.filterLinks("FilteredEditList_SpecialWatchlist");
        }
        if (action === "edit") {
            var spaces  = this.parseNamespaces();
            this.toggleLinks(spaces);
        }
    },

    //------------------------------------------------------------------------------
    //## toggle-links

    /** extends header structure and add toggle buttons for all checkboxes */
    toggleLinks: function(spaces) {
        var form    = jsutil.DOM.fetch(document, "form", null, 0);

        // add invert buttons for single namespaces
        for (var i=0; i<spaces.length; i++) {
            var space   = spaces[i];
            var button  = this.toggleButton(space.ul);
            jsutil.DOM.insertAfter(space.h2, button );
        }

        // add  gobal invert button with header
        var globalHdr   = document.createElement("h2");
        globalHdr.textContent   = SpecialWatchlist.msg.global;
        var button  = this.toggleButton(form);
        var target  = form.elements[form.elements.length-1];
        jsutil.DOM.insertBeforeMany(target,  [
            globalHdr, button,
            // HACK to get some space
            document.createElement("br"),
            document.createElement("br")//,
        ]);
    },

    /** creates a toggle button for all input children of an element */
    toggleButton: function(container) {
        return Links.functionLink(SpecialWatchlist.msg.invert, function() {
            var inputs  = container.getElementsByTagName("input");
            for (var i=0; i<inputs.length; i++) {
                var el  = inputs[i];
                if (el.type === "checkbox")
                    el.checked  = !el.checked;
            }
        });
    },

    //------------------------------------------------------------------------------
    //## list parser

    parseNamespaces: function() {
        var out     = [];
        var form    = jsutil.DOM.fetch(document, "form", null, 0);
        var uls     = jsutil.DOM.fetch(form, "ul");
        for (var i=0; i<uls.length; i++) {
            var ul  = uls[i];
            var h2  = jsutil.DOM.previousElement(ul);
            var ns  = h2 ? h2.textContent : "";
            out.push({ ul: ul, h2: h2, ns: ns });
        }
        return out;
    }//,
};
SpecialWatchlist.msg = {
    invert:     "Invertieren",
    article:    "Artikel",
    global:     "Alle"//,
};

//======================================================================
//## app/extension/SpecialPrefixindex.js 

/** extends Special:PrefixIndex */
var SpecialPrefixindex = {
    /** onload initializer */
    init: function() {
        this.sortItems();
    },

    //------------------------------------------------------------------------------
    //## private

    /** sort items into a straight list */
    sortItems: function() {
        var table   = jsutil.DOM.fetch('bodyContent', "table", null, 2);
        if (!table) return; // no search results
        var tds     = jsutil.DOM.fetch(table, "td");
        var ol      = document.createElement("ol");
        for (var i=0; i<tds.length; i++) {
            var td  = tds[i];
            var li  = document.createElement("li");
            var c   = td.firstChild.cloneNode(true)
            li.appendChild(c);
            ol.appendChild(li);
        }
        table.parentNode.replaceChild(ol, table);
    }//,
};

//======================================================================
//## app/feature/ForSite.js 

/** links for the whole site */
var ForSite = {
    init: function() {
        Config.patch(ForSite.cfg);
    },
    
    /** a link to new pages */
    linkNewPages: function() {
        return Links.pageLink(ForSite.msg.newpages, {
            title:  Titles.specialPage("NewPages"),
            limit:  20//,
        });
    },

    /** a link to new pages */
    linkNewusers: function() {
        return Links.pageLink(ForSite.msg.newusers, {
            title:  Titles.specialPage("Log"),
            type:   "newusers",
            limit:  50//,
        });
    },

    /** a bank of links to interesting pages */
    bankProjectPages: function() {
        var pages   = ForSite.cfg.projectPages;
        if (!pages) return null;
        var out = [];
        for (var i=0; i<pages.length; i++) {
            var page    = pages[i];
            var link    =  Links.readLink(page[0], page[1]);
            out.push(link);
        }
        return out;
    },

    /** return a link for fast logfiles access */
    linkAllLogsPopup: function() {
        function selected(userdata) {
            window.location.href    = Wiki.readURL(Titles.specialPage("Log", userdata.toLowerCase()));
        }
        return this.linkAllPopup(
            ForSite.msg.logLabel,
            Titles.specialPage("Log"),
            ForSite.cfg.logs,
            selected);
    },

    /** return a link for fast logfiles access */
    linkAllSpecialsPopup: function() {
        function selected(userdata) {
            window.location.href    = Wiki.readURL(Titles.specialPage(userdata));
        }
        return this.linkAllPopup(
            ForSite.msg.specialLabel,
            Titles.specialPage("SpecialPages"),
            ForSite.cfg.specials,
            selected);
    },

    //------------------------------------------------------------------------------
    //## private

    /** returns a linkPopup */
    linkAllPopup: function(linkLabel, mainPage, pages, selectFunc) {
        var mainLink    = Links.readLink(linkLabel, mainPage);
        var popup       = new PopupMenu(selectFunc);
        for (var i=0; i<pages.length; i++) {
            var page    = pages[i];
            popup.item(page, page); // the page is the userdata
        }
        new PopupSource(mainLink, popup);
        return mainLink;
    }//,
}
ForSite.cfg = {
    /** which logs are displayed in the popup */
    logs: [
        "Move", "Block", "Protect", "Delete", "Upload"
        
],

    /** which specialpages are displayed in the opoup */
    specials:[
        "AllMessages", "AllPages", "CategoryTree", "IPBlockList", "Linksearch", "ListUsers", "NewImages", "PrefixIndex",
        
],
    
    /** useful pages in this wiki */
    projectPages: null,
    
    // domain-specific data
    "de.wikipedia.org": {
        projectPages: [
            [   "VM",   "Wikipedia:Vandalismusmeldung"          ],
            [   "EW",   "Wikipedia:Entsperrwünsche"             ],
            [   "SP",   "Wikipedia:Sperrprüfung"                ],
            [   "LP",   "Wikipedia:Löschprüfung"                ],
            [   "LK",   "Wikipedia:Löschkandidaten"             ],
            [   "SL",   "Kategorie:Wikipedia:Schnelllöschen"    ],
            
]//,
    },
    "de.wikiversity.org": {
        projectPages: [
            [   "Löschen",  "Kategorie:Wikiversity:Löschen" ]//,
        ]//,
    },
    "de.wikisource.org": {
        projectPages: [
            [   "LK",   "Wikisource:Löschkandidaten"            ],
            [   "SL",   "Kategorie:Wikisource:Schnelllöschen"   ]//,
        ]//,
    }//,    
};
ForSite.msg = {
    logLabel:       "Logs",
    specialLabel:   "Spezial",

    newpages:       "Neuartikel",
    newusers:       "Newbies",
};

//======================================================================
//## app/feature/ForPage.js 

/** links for arbitrary pages */
var ForPage = {
    /** returns a link to the logs for a given page */
    linkLogAbout: function(title) {
        return Links.pageLink(ForPage.msg.pageLog,  {
            title:  Titles.specialPage("Log"),
            page:   title
        });
    }//,
};
ForPage.msg = {
    pageLog:    "Seitenlog"//,
};

//======================================================================
//## app/feature/ForUser.js 

/** links for users */
var ForUser = {
    /** returns a link to the homepage of a user */
    linkHome: function(user) {
        return Links.readLink(ForUser.msg.home, Titles.userPage(user));
    },

    /** returns a link to the talkpage of a user */
    linkTalk: function(user) {
        return Links.readLink(ForUser.msg.talk, Titles.userTalkPage(user));
    },

    /** returns a link to new messages or null when none exist */
    linkNews: function(user) {
        return Links.readLink(ForUser.msg.news, Titles.userTalkPage(user), { diff: "cur" });
    },

    /** returns a link to a users contributions */
    linkContribs: function(user) {
        return Links.readLink(ForUser.msg.contribs, Titles.specialPage("Contributions", user));
    },


    /** returns a link to the blockpage for a user */
    linkBlock: function(user) {
        return Links.readLink(ForUser.msg.block,    Titles.specialPage("BlockIP", user));
    },

    /** returns a link to a users emailpage */
    linkEmail: function(user) {
        return Links.readLink(ForUser.msg.email,    Titles.specialPage("EmailUser", user));
    },

    /** returns a link to a users log entries */
    linkLogsAbout: function(user) {
        return Links.pageLink(ForUser.msg.logsAbout, {
            title:  Titles.specialPage("Log"),
            page:   Titles.userPage(user)//,
        });
    },

    /** returns a link to a users log entries */
    linkLogsActor: function(user) {
        return Links.pageLink(ForUser.msg.logsActor, {
            title:  Titles.specialPage("Log"),
            user:   user//,
        });
    },

    /** returns a link to show subpages of a user */
    linkSubpages: function(user) {
        return Links.pageLink(ForUser.msg.subpages, {
            title:      Titles.specialPage("PrefixIndex"),
            namespace:  2,  // User
            from:       user + "/"//,
        });
    },
    
    /** various checks for anonymous users */
    bankIP: function(ip) {
        return ForUser.cfg.ipChecks.map(function(def) {
            return Links.urlLink(def.label, def.template.template({ip:ip}));
        });
    }//,
};
ForUser.msg = {
    home:       "Benutzer",
    talk:       "Diskussion",
    email:      "Anmailen",
    contribs:   "Contribs",
    block:      "Sperren",

    news:       "☏",
    logsAbout:  "Log",
    logsActor:  "Act",
    subpages:   "Sub",
};
ForUser.cfg = {
    
ipChecks: [
        { label: "Honey",   template: "http://www.projecthoneypot.org/ip_${ip}" },
        { label: "Whois",   template: "http://whois.domaintools.com/${ip}"                      }//,
    ]//,
};

//======================================================================
//## app/feature/FilteredEditList.js 

/** filters edit lists by name/ip */
var FilteredEditList = {
    /** onload initializer */
    filterLinks: function(cookieName) {
        var bodyContent = jsutil.DOM.get('bodyContent');

        // tag list items with a CSS class "is-ip" or "is-named"
        var uls = jsutil.DOM.fetch(bodyContent, "ul", "special");
        for (var i=0; i<uls.length; i++) {
            var ul  = uls[i];
            var lis = jsutil.DOM.fetch(ul, "li");
            for (var j=0; j<lis.length; j++) {
                var li  = lis[j];
                
var a   = jsutil.DOM.fetch(li, "a", "mw-userlink", 0);
                if (!a) continue;
                if (IP.isV4(a.textContent)) jsutil.DOM.addClass(li, "is-ip");
                else                        jsutil.DOM.addClass(li, "is-named");
            }
        }

        /** changes the filter state */
        function update(link, state) {
            board.select(link);
            if (state === "named")  jsutil.DOM.addClass(    bodyContent, "hide-ip");
            else                    jsutil.DOM.removeClass( bodyContent, "hide-ip");
            if (state === "ip")     jsutil.DOM.addClass(    bodyContent, "hide-named");
            else                    jsutil.DOM.removeClass( bodyContent, "hide-named");
            jsutil.Cookie.set(cookieName, state);
        }

        /** adds a filter-change link to the switchBoard */
        function action(state) {
            var link    = Links.functionLink(
                FilteredEditList.msg.state[state],
                function() { update(link, state); }
            );
            board.add(link);
            if (state === initial)  update(link, state);
        }

        // create state switchboard
        var initial = jsutil.Cookie.get(cookieName);
        if (!initial)   initial = "all";
        var states  = [ "all", "named", "ip" ];
        var board   = new SwitchBoard();
        for (var i=0; i<states.length; i++) action(states[i]);

        var target  = jsutil.DOM.fetch(document, "form", null, 0);
        if (!target)    return;

        // HACK to get some space
        var br  = document.createElement("br");
        br.style.lineHeight = "30%";
        
        var after   = document.createElement("div");
        after.className = "visualClear";

        jsutil.DOM.insertAfterMany(target, 
            jsutil.DOM.textAsNodeMany([
                br,
                FilteredEditList.msg.intro,
                board.component,
                after
            ])
        );
    }//,
};
FilteredEditList.msg = {
    intro:  "Filter: ",
    state: {
        all:    "Alle",
        ip:     "Ips",
        named:  "Angemeldete"//,
    }//,
};

//======================================================================
//## app/feature/FastWatch.js 

/** page watch and unwatch without reloading the page */
var FastWatch = {
    init: function() {
        /** initialize link */
        function initView() {
            var watch   = jsutil.DOM.get('ca-watch');
            var unwatch = jsutil.DOM.get('ca-unwatch');
            if (watch)      exchangeItem(watch,     true);
            if (unwatch)    exchangeItem(unwatch,   false);
        }

        /** talk to the server, then updates the UI */
        function changeState(link, watched) {
            function update() {
                var watch   = jsutil.DOM.get('ca-watch');
                var unwatch = jsutil.DOM.get('ca-unwatch');
                if ( watched && watch  )    exchangeItem(watch,     false);
                if (!watched && unwatch)    exchangeItem(unwatch,   true);
            }
            var feedback    = new FeedbackLink(link);
            Actions.watchedPage(feedback, Page.title, watched, update);
        }

        /** create a li with a link in it */
        function exchangeItem(target, watchable) {
            var li      = document.createElement("li");
            li.id       = watchable ? "ca-watch"            : "ca-unwatch";
            var label   = watchable ? FastWatch.msg.watch   : FastWatch.msg.unwatch;
            var a       = Links.functionLink(label, function() {
                changeState(a, watchable);
            });
            jsutil.DOM.addClass(a, "link-immediate");
            li.appendChild(a);
            target.parentNode.replaceChild(li, target);
        }

        
initView();
    }//,
};
FastWatch.msg = {
    watch:      "Beobachten",
    unwatch:    "Entobachten"//,
};

//======================================================================
//## app/feature/FastDelete.js 

/** one-click delete */
var FastDelete = {
    /** returns a link which prompts or popups reasons and then deletes */
    linkDeletePopup: function(title) {
        var self    = this;
        var msg     = FastDelete.msg;
        var cfg     = FastDelete.cfg;
        var link    = Links.promptPopupLink(msg.label, msg.prompt, cfg.reasons, function(reason) {
            self.fastDelete(title, reason);
        });
        link.title  = msg.tooltip.deletePage;
        return link;
    },

    
/** delete an article with a reason */
    fastDelete: function(title, reason) {
        var feedback    = new FeedbackArea();
        Actions.deletePage(feedback, title, reason, Afterwards.maybeReloadFunc(title));
    }//,
};
FastDelete.cfg = {
    reasons:    null//,
};
FastDelete.msg = {
    prompt:     "Warum löschen?",
    label:      "löschen",
    tooltip: {
        deletePage: "Seite löschen"//,
    }//,
};

//======================================================================
//## app/feature/FastRestore.js 

/** old revision restore */
var FastRestore = {
    init: function() {
        Config.patch(FastRestore.cfg);
    },
    
    /** returns a link restoring a given version */
    linkRestore: function(title, oldid, user, date, doneFunc) {
        var summary = FastRestore.cfg.summary(title, oldid, user, date);
        var restore = Links.functionLink(FastRestore.msg.label, function() {
            var feedback    = new FeedbackLink(restore);
            Actions.restoreVersion(feedback, title, oldid, summary, doneFunc);
        });
        jsutil.DOM.addClass(restore, "link-immediate");
        return restore;
    }//,
};
FastRestore.msg = {
    label:  "restore"//,
};
FastRestore.cfg = {
    "de": { 
        /** compile a summary for a restore-action */
        summary: function(title, oldid, user, date) {
            return "zurück auf " + Markup.userLink(user, user) + " " + date + " (" + oldid + ")";
        }//,
    },
    "en": {
        /** compile a summary for a restore-action */
        summary: function(title, oldid, user, date) {
            return "back to " + Markup.userLink(user, user) + " " + date + " (" + oldid + ")";
        }//,
    }//,
};

//======================================================================
//## app/feature/FastUndo.js 

/** ajax undo */
var FastUndo = {
    /** extend an undo link */
    patchLink: function(link, doneFunc) {
        var params  = Wiki.decodeURL(link.href);
        if (!params.undo)   return; // TODO necessary?
        jsutil.DOM.addClass(link, "link-immediate");
        link.onclick    = function() {
            var feedback    = new FeedbackLink(link);
            Actions.undoVersion(feedback, params.title, params.undo, params.undoafter, doneFunc);
            return false;
        };
    }//,
};

//======================================================================
//## app/feature/FastRollback.js 

/** ajax rollback */
var FastRollback = {
    init: function() {
        Config.patch(FastRollback.cfg);
    },
    
    /** extend a rollback link */
    patchLink: function(link, doneFunc) {
        
var params  = Wiki.decodeURL(link.href);
        if (!params.token)  return null;    // TODO necessary?
        var msg     = FastRollback.msg;
        var cfg     = FastRollback.cfg;
        
        var promptLabel = cfg.requireReason ? msg.promptLabel : null;
        var mainLink    = Links.promptPopupLink(msg.label, promptLabel, cfg.reasons, function(reason) {
            var feedback    = new FeedbackLink(mainLink);
            var summary     = cfg.commentFunc(params.from, reason);
            Actions.rollbackEdit(feedback, params.title, params.from, params.token, summary, doneFunc);
            return false;
        });
        //link.title    = msg.tooltip.deletePage;
        link.parentNode.replaceChild(mainLink, link);
        return mainLink;
    },
};
FastRollback.msg = {
    label:      "rollback",
    promptLabel: "Rollback - grund?"//,
};
FastRollback.cfg = {
    requireReason:  false,
    commentFunc:    function(victim, reason) { return reason === null ? null : "revert " + Markup.contribsLink(victim, victim) + ": " + reason; },
    reasons:        null,
    "de": {},
    "en": {}//,
};

//======================================================================
//## app/feature/UserRevert.js 

/** add a revert-button for non-admins */
var UserRevert = {  
    init: function() {
        Config.patch(UserRevert.cfg);
    },
    
    /** returns a link revertung edits by a given user */
    linkRevert: function(title, badUser, doneFunc) {
        var self    = this;
        var revert  = Links.functionLink(UserRevert.msg.revert, function() {
            self.revert(feedback, title, badUser, Afterwards.maybeHistoryFunc(title));
        });
        var feedback    = new FeedbackLink(revert);
        jsutil.DOM.addClass(revert, "link-immediate");
        return revert;
    },

    //------------------------------------------------------------------------------
    //## private
    
    /** reverts new edits of a given user */
    revert: function(feedback, title, badUser, doneFunc) {
        function phase1() {
            Actions.newEdits(feedback, title, badUser, phase2);
        }
        function phase2(title, user, previousUser, revid, timestamp) {
            var summary = UserRevert.cfg.summary(title, user, previousUser, revid, timestamp);
            Actions.restoreVersion(feedback, title, revid, summary, doneFunc);
        }
        phase1();
    }//,
};
UserRevert.msg = {
    revert: "revert"//,
};
UserRevert.cfg = {
    "de": {
        summary: function(title, user, previousUser, revid, timestamp) {
            // msg:revertpage 
            // Änderungen von [[{{ns:user}}:$2|$2]] ([[{{ns:special}}:Contributions/$2|Beiträge]]) rückgängig gemacht und letzte Version von $1 wiederhergestellt
            return "Änderungen von " +
                    Markup.userLink(user, user) + " " +
                    "(" + Markup.contribsLink(user, "Beiträge") + ") " +
                    "rückgängig gemacht und letzte Version von " +
                    previousUser + " wiederhergestellt";
        }//, 
    },
    "en": {
        summary: function(title, user, previousUser, revid, timestamp) {
            // msg:revertpage 
            // Reverted edits by [[Special:Contributions/$2|$2]] ([[User talk:$2|talk]]) to last version by $1
            return "Reverted edits by " +
                    Markup.contribsLink(user, user) + " " +
                    "(" + Markup.talkLink(user, "talk") + ") " +
                    "to last version by " + previousUser;
        }//,
    }//,
};

//======================================================================
//## app/feature/TemplatePage.js 

/** puts templates into the current page */
var TemplatePage = {
    /** return an Array of links to actions for normal pages */
    bankAllPage: function(title) {
        // HACK does not make sense on other wikis
        if (Wiki.domain !== "de.wikipedia.org") return null;

        var msg     = TemplatePage.msg;
        var self    = this;
        return [
            Links.promptLink(msg.urv.label, msg.urv.prompt, function(source) { self.urv(title, source); }),
            Links.promptLink(msg.qs.label,  msg.qs.prompt,  function(reason) { self.qs(title, reason);  }),
            Links.promptLink(msg.la.label,  msg.la.prompt,  function(reason) { self.la(title, reason);  }),
            Links.promptLink(msg.sla.label, msg.sla.prompt, function(reason) { self.sla(title, reason); })//,
        ];
    },

    //------------------------------------------------------------------------------
    //## private

    /** puts an SLA template into an article */
    sla: function(title, reason) {
        var template    = "löschen";
        
        // reason as parameter
        var summary = Markup.template(template, reason);
        var text    = Markup.template(template, reason + Markup.SIGAPP)
        var sepa    = Markup.LF;
        var feedback    = new FeedbackArea();
        Actions.prependText(feedback, title,  text, summary, sepa, false, Afterwards.maybeReloadFunc(title));
    },
    
    /** puts an LA template into an article */
    la: function(title, reason) {
        // TODO should automatically nowiki an SLA
        var template    = "subst:Löschantrag";
        var listPage    = "Wikipedia:Löschkandidaten";
        
        // images have a separate page
        var split   = Namespace.split(title);
        if (split.nsIndex === 6) {
            // not necessary
            // template = "subst:Löschantrag_Bild";
            listPage    = "Wikipedia:Löschkandidaten/Bilder";
        }
        
        var self        = this;
        var feedback    = new FeedbackArea();
        // insert template
        function phase1() {
            // reason as parameter
            var summary = Markup.template(template, reason);
            var text    = Markup.template(template, reason + Markup.SIGAPP);
            var sepa    = Markup.LF;
            Actions.prependText(feedback, title, text, summary, sepa, false, phase2);
        }
        // add to list page
        function phase2() {
            // daily page
            var page    = listPage + "/" + self.currentDate();
            var text    = Markup.h2(Markup.referenceLink(title)) + Markup.LF + reason + Markup.SIGAPP;
            var summary = Markup.link(title) + Markup.SP + Markup.DASH + Markup.SP + reason;
            var sepa    = Markup.LF;
            Actions.appendText(feedback, page, text, summary, sepa, true, Afterwards.maybeReloadFunc(title));
        }
        phase1();
    },
    
    /** puts an QS template into an article and links to the article from the list page */
    qs: function(title, reason) {
        var template    = "subst:QS";
        var listPage    = "Wikipedia:Qualitätssicherung";
        
        var self        = this;
        var feedback    = new FeedbackArea();
        // insert template
        function phase1() {
            // reason as parameter
            var summary = Markup.template(template, reason);
            var text    = Markup.template(template, reason + Markup.SIGAPP);
            var sepa    = Markup.LF;
            Actions.prependText(feedback, title, text, summary, sepa, false, phase2);
        }
        // add to list page
        function phase2() {
            // daily page
            var page    = listPage + "/" + self.currentDate();
            var text    = Markup.h2(Markup.referenceLink(title)) + Markup.LF + reason + Markup.SIGAPP;
            var summary = Markup.link(title) + Markup.SP + Markup.DASH + Markup.SP + reason;
            var sepa    = Markup.LF;
            Actions.appendText(feedback, page, text, summary, sepa, true, Afterwards.maybeReloadFunc(title));
        }
        phase1();
    },
    
    /** puts an URV template into an article */
    urv: function(title, source) {
        var template    = "URV";
        var listPage    = "Wikipedia:Löschkandidaten/Urheberrechtsverletzungen";
        
        var self        = this;
        var feedback    = new FeedbackArea();
        // insert template
        function phase1() {
            // reason behind template
            var summary = Markup.template(template) + Markup.SP + "von" + Markup.SP + Markup.web(source);
            var text    = Markup.template(template) + Markup.SP + Markup.web(source) + Markup.SIGAPP;
            // replace complete text
            var replaceFunc = jsutil.Function.constant(text);
            Actions.replaceText(feedback, title, replaceFunc, summary, false, false, phase2);
        }
        // add to list page
        function phase2() {
            // single page with ordered list
            var page    = listPage;
            var text    = "# " + Markup.referenceLink(title) + Markup.SP + "von" + Markup.SP + Markup.web(source) + Markup.SIGAPP;
            var summary = Markup.link(title) + Markup.SP + "von" + Markup.SP + Markup.web(source);
            function replace(t) { return t.replace(/\s+$/, "") + Markup.LF + text; }
            Actions.replaceText(feedback, page, replace, summary, false, true, Afterwards.maybeReloadFunc(title));
        }
        phase1();
    },
    
    //------------------------------------------------------------------------------
    //## helper

    /** returns the current date in the format the LKs are organized */
    currentDate: function() {
        var months  = [ "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli",
                        "August", "September", "Oktober", "November", "Dezember" ];
        var now     = new Date();
        var year    = now.getYear();
        if (year < 999) year    += 1900;
        return now.getDate() + ". " + months[now.getMonth()] + " " + year;
    }//,
};
TemplatePage.msg = {
    urv: {
        label:  "URV",
        prompt: "URV - Quelle?"//,
    },
    qs: {
        label:  "QS",
        prompt: "QS - Begründung?"//,
    },
    la: {
        label:  "LA",
        prompt: "LA - Begründung?"//,
    },
    sla: {
        label:  "SLA",
        prompt: "SLA - Begründung?"//,
    }//,
};

//======================================================================
//## app/feature/TemplateTalk.js 

/** puts templates into user talkpages */
var TemplateTalk = {
    init: function() {
        Config.patch(TemplateTalk.cfg);
    },
    
    /** return an Array of links for userTalkPages or null if none exist */
    bankOfficial: function(user) {
        return this.talksBank(user, "official", false);
    },

    /** return an Array of links for userTalkPages or null if none exist */
    bankPersonal: function(user) {
        return this.talksBank(user, "personal", true);
    },

    //------------------------------------------------------------------------------
    //## private
    
    /** returns a talkArray localized to the currrent wiki */
    talksBank: function(user, bankName, ownTemplate) {
        var talks   = TemplateTalk.cfg.talks;
        if (!talks) return null;
        var bank    = talks[bankName];
        if (!bank || bank.length === 0) return null;
        
        var out = [];
        for (var i=0; i<bank.length; i++) {
            var template    = bank[i];
            out.push(this.linkTalkTo(
                    user, template, ownTemplate));
        }
        return out;
        
    },

    /** creates a link to "talk" to a user */
    linkTalkTo: function(user, template, ownTemplate) {
        if (template.constructor === String) {
            var handler = this.talkToOld.bind(this, user, template, ownTemplate);
            var link    = Links.functionLink(template, handler);
            jsutil.DOM.addClass(link, "link-immediate");
            return link;
        }
        else {
            var handler = this.talkToNew.bind(this, user, template.subst, template.separator, template.summary);
            var link    = Links.functionLink(template.label, handler);
            jsutil.DOM.addClass(link, "link-immediate");
            return link;
        }
    },

    /** puts a signed talk-template into a user's talkpage */
    talkToOld: function(user, template, ownTemplate) {
        var title   = Titles.userTalkPage(user);
        var inner   = ownTemplate
                    ? "subst:" + Titles.userPage(Wiki.user, template)
                    : "subst:" + template;
        var text    = Markup.template(inner) + Markup.SP + Markup.SIGAPP + Markup.LF;
        var sepa    = Markup.LINE + Markup.LF;
        var feedback    = new FeedbackArea();
        Actions.appendText(feedback, title, text, template, sepa, true, Afterwards.maybeReloadFunc(title));
    },
    
    /** puts a talk-template into a user's talkpage */
    talkToNew: function(user, subst, separator, summary) {
        var title       = Titles.userTalkPage(user);
        var home        = Titles.userPage(Wiki.user);
        var text        = subst.template({ home: home });
        var feedback    = new FeedbackArea();
        Actions.appendText(feedback, title, text, summary, separator, true, Afterwards.maybeReloadFunc(title));
    }//,
};
TemplateTalk.cfg = {
    talks: null,
    
    "de.wikipedia.org": { 
        talks: {
            //official: [ "Hallo",      "Test"  ],
            official: [
                {   
                    label:      "Hallo",
                    subst:      "{{subst:Hallo}}",  
                    summary:    "Vorlage:Hallo",    
                    separator:  "\n----\n"      
                },
                {   
                    label:      "Test", 
                    subst:      "{{subst:Test}}",
                    summary:    "Vorlage:Test", 
                    separator:  "\n----\n"
                }//,
            ],
            personal:   [ ]//,
        }//,
    },
    "en.wiktionary.org": { 
        talks: {
            // official:    [ "welcome" ],
            official:   [ 
                {   
                    label:      "welcome",
                    subst:      "{{subst:Welcome}} -- ~~~~",
                    summary:    "Template:Welcome",
                    separator:  "\n----\n"
                }//,
            ],
            personal:   [ ]//,
        }//,
    }//,
};

//======================================================================
//## app/feature/UserPage.js 

/** cares for pages in the user namespace */
var UserPage = {
    /** create bank of readLinks to private pages */
    bankGoto: function() {
        function addLink(name) {
            var link    = Links.readLink(name, Titles.userPage(Wiki.user, name));
            out.push(link);
        }
        var out     = [];
        var names   = UserPage.cfg.pages;
        if (names === null
        || names.length === 0)  return null;
        for (var i=0; i<names.length; i++) {
            addLink(names[i]);
        }
        return out;
    }//,
};
UserPage.cfg = {
    pages: null,    // [ "tmp", ... ]
};

//======================================================================
//## app/feature/UserBookmarks.js 

/** manages a personal bookmarks page  */
var UserBookmarks = {
    /** return an Array of links for a lemma. if it's left out, uses the current page */
    bankView: function(lemma) {
        return [ this.linkView(), this.linkMark(lemma) ];
    },

    /** return the absolute page link */
    linkView: function() {
        var link    = Links.readLink(UserBookmarks.msg.view, this.pageTitle());
        link.title  = UserBookmarks.msg.tooltip.view;
        return link;
    },

    /** add a bookmark on a user's bookmark page. if the page is left out, the current is added */
    linkMark: function(lemma) {
        var self    = this;
        var msg     = UserBookmarks.msg;
        var cfg     = UserBookmarks.cfg;
        var link    = Links.promptPopupLink(msg.add, msg.prompt, cfg.reasons, function(reason) {
            if (lemma)  self.arbitrary(reason, lemma);
            else        self.current(reason);
        });
        link.title  = msg.tooltip.add;
        return link;
    },

    //------------------------------------------------------------------------------
    //## private

    /** add a bookmark for an arbitrary page */
    arbitrary: function(remark, lemma) {
        var text    = "*\[\[:" + lemma + "\]\]";
        if (remark) text    += " " + remark;
        text        += "\n";
        this.prepend(text);
    },

    /** add a bookmark on a user's bookmark page */
    current: function(remark) {
        var text    = Markup.STAR;
        if (Page.whichSpecial()) {
            var temp    = Wiki.smushedParams(Page.params);
            var complete    = true;
            for (var key in temp) {
                complete    = key === "title";
                if (!complete)  break;
            }
            
            if (complete) {
                text    += Markup.referenceLink(temp.title);
            }
            else {
                text    += Markup.web(Wiki.encodeURL(temp), temp.title);
            }
        }
        else {
            var lemma   = Page.title;
            var perma   = Page.perma;
            var oldid   = Page.params["oldid"];
            var diff    = Page.params["diff"];
            var mode;
            var extra;
            if (oldid && diff) {
                if (diff === "prev"
                ||  diff === "next"
                ||  diff === "next"
                ||  diff === "cur") mode    = diff;
                else
                if (diff === "cur"
                ||  diff === "0")   mode    = "cur";
                else                mode    = "diff";
                extra   = Wiki.encodeURL({
                    title:  lemma,
                    oldid:  oldid,
                    diff:   diff//,
                });             
            }
            else if (oldid) {
                mode    = "old";
                extra   = Wiki.encodeURL({
                    title:  lemma,
                    oldid:  oldid//,
                });
            }
            else if (perma) {
                mode    = "perma";
                extra   = perma;
            }
            else {
                mode    = "none";
                extra   = null;
            }
            text += Markup.referenceLink(lemma);
            if (extra)  text    += " <small>[" + extra + " " + mode + "]</small>";
        }
        if (remark) text    += " " + remark;
        text    += Markup.LF;
        this.prepend(text);
    },
    
    /** add text to the bookmarks page */
    prepend: function(text) {
        var feedback    = new FeedbackArea();
        Actions.prependText(feedback, this.pageTitle(), text, "", null, true);
    },

    /** the title of the current user's bookmarks page */
    pageTitle: function() {
        return Titles.userPage(Wiki.user, UserBookmarks.cfg.pageTitle);
    }
};
UserBookmarks.cfg = {
    pageTitle:  "bookmarks",
    reasons:    null//,
};
UserBookmarks.msg = {
    view:       "Bookmarks",
    add:        "Merken",
    prompt:     "Bookmark - Kommentar?",
    tooltip: {
        view:   "Bookmarkseite anzeigen",
        add:    "Auf der Bookmarkseite eintragen"//,
    }//,
};

//======================================================================
//## app/feature/Redirect.js 

/** creates redirects for the current page */
var Redirect = {
    /** returns a link changing a page into a redirect to its first link */
    linkModify: function(title) {
        var self    = this;
        var msg     = Redirect.msg;
        var modify  = Links.functionLink(msg.modify, function() {
            self.modify(title);
        });
        jsutil.DOM.addClass(modify, "link-immediate");
        modify.title    =msg.tooltip.redirect;
        return modify;
    },
    
    //------------------------------------------------------------------------------
    //## private
    
    /** changes a page into a redirect to its first link */
    modify: function(title) {
        var feedback    = new FeedbackArea();
        var success     = true;
        /** replace page with redirect */
        function change(s) {
            var links   = WikiLink.parseAll(s);
            if (links.length !== 1) {
                // do nothing
                success = false;
                return s;
            }
            var target  = links[0].title;
            return Markup.redirect(target);
        }
        /** display abort or reload if we're on the current page */
        function done() {
            if (!success) {
                feedback.failure(Redirect.msg.notSingle);
                return;
            } 
            Afterwards.maybeReload(title);
        }
        Actions.replaceText(feedback, title, change, "", true, false, done);
    }//,
};
Redirect.msg = {
    modify:     "Redir",
    notSingle:  "erwartete einen einzigen link",
    tooltip: {
        redirect:   "seite in einen redirect umwandeln"//,
    }//,
};

//======================================================================
//## app/feature/EditWarning.js 

/** displays an image behind the edit link on other people's user page */
var EditWarning = {
    init: function() {
        var name    = Namespace.scan(2, Page.title);
        if (!name)                      return;
        if (name.indexOf("/") !== -1)   return;
        if (name === Wiki.user)         return;
        
        var ed  = jsutil.DOM.get('ca-edit');
        if (!ed)    return;
        var a   = jsutil.DOM.fetch(ed, "a", null, 0);
        if (!a)     return;
        
        ed.style.background = "left url(" + EditWarning.cfg.image + ");";
        a.style.background  = "transparent";
    }//,
};
EditWarning.cfg = {
    image:  "http://upload.wikimedia.org/wikipedia/commons/thumb/f/ff/Stop_hand.png/32px-Stop_hand.png"//,
};

//======================================================================
//## app/feature/Google.js 

/** provides links to google for article lemmata */
var Google = {
    /** returns a link searching google for a given title */
    link: function(title) {
        var search  = this.searchString(title);
        var url     = Google.cfg.search + encodeURIComponent(search);
        var link    = Links.urlLink(Google.cfg.label, url);
        jsutil.DOM.addClass(link, "google");
        link.title  = Google.msg.tooltip.google;
        return link;
    },
    
    /** compiles the search terms into a search string */
    searchString: function(title) {
        // search for non-article pages literally
        //### HACK not every colon is a namespace separator
        if (title.indexOf(":") !== -1) {
            return title.indexOf(" ") !== -1 
                    ? '"' + title + '"'
                    : title;
        }
        
        var parts   = this.titleParts(title);
        var out     = "";
        for (var i=0; i<parts.length; i++) {
            var part    = parts[i];
            var spaced  = part.indexOf(" ") !== -1;
            if (spaced) out += '"' + part + '"';
            else        out += part;
            out += " ";
        }
        // don't search wikipedia (and its mirrors)
        return out.trim() + " -wikipedia";
    },
    
    /** splits the title into a list of search terms */
    titleParts: function(title) {
        var parts   = title.split(/[()]/);
        var out     = [];
        for (var i=0; i<parts.length; i++) {
            var part    = this.normalizePart(parts[i]);
            if (part === "")    continue;
            out.push(part);
        }
        return out;
    },
    
    /** removes unusable parts from a search term */
    normalizePart: function(title) {
        return title.replace(/"/g, " ")         // metacharacters
                    .replace(/^[+-]/g, "")      // metacharacters
                    .replace(/ {2,}/g, " ")     // whitespace
                    .trim();                    // whitespace
    }//,
};
Google.cfg = {
    search: "http://www.google.com/search?num=100&hl=en&q=",
    label:  "Google",   // "Ⓖ", "⎈", font-size: 90%;
};
Google.msg = {
    tooltip: {
        google: "lemma nachgooglen"//,
    }//,
};

//======================================================================
//## app/feature/SectionEdit.js 

/** beautify sectionedit links */
var SectionEdit = {
    init: function() {
        if (SectionEdit.cfg.section0) {
            this.section0();
        }
        this.sectionN();
    },
    
    section0: function() {
        if (!Page.editable)     return;
        var firstHeading    = jsutil.DOM.get('firstHeading');
        if (!firstHeading)  return;
        var a   = Links.pageLink(SectionEdit.cfg.symbol, {
            title:      Page.title,
            action:     "edit",
            section:    0
        });
        var span    = document.createElement("span");
        span.id         = "firstsectionedit";
        span.className  = "mw-editsection";
        span.appendChild(a);
        jsutil.DOM.insertBegin(firstHeading, span);
    },
    
    sectionN: function() {
        var bodyContent = jsutil.DOM.get('bodyContent');
        for (j=1; j<=6; j++) {
            var tag     = "h" + j;
            var hdrs    = jsutil.DOM.fetch(bodyContent, tag);
            for (var i=0; i<hdrs.length; i++) {
                var hdr     = hdrs[i];
                
                // replace link text with symbol
                var section = jsutil.DOM.fetch(hdr, "span", "mw-editsection", 0);
                if (!section)   continue;
                var a   = jsutil.DOM.fetch(section, "a", null, 0);
                a.textContent   = SectionEdit.cfg.symbol;
                
                // remove cruft around the link
                while (section.firstChild && section.firstChild !== a)
                        section.removeChild(section.firstChild);
                while (section.lastChild && section.lastChild !== a)
                        section.removeChild(section.lastChild);
                
}
        }
    }//,
}
SectionEdit.cfg = {
    symbol:     "✍",    // ✎ ✍ ✐ ✑ ✒
    section0:   false//,
};

//======================================================================
//## app/portlet/Search.js 

/** #p-search */
Search = {
    /** remove strange whitespace messing up FF */
    init: function() {
        var form    = document.forms["searchform"];
        var nodes   = form.childNodes;
        for (var i=nodes.length-1; i>=0; i--) {
            var node    = nodes[i];
            if (node.nodeType !== Node.TEXT_NODE)   continue;
            jsutil.DOM.removeNode(node);
        }
    }
};

//======================================================================
//## app/portlet/Lang.js 

/** #p-lang */
var Lang = {
    id: 'p-lang',

    /** replace the #p-lang portlet with a shorter variant */
    init: function() {
        var langs   = Page.languages();
        if (!langs.length)  return;
        
        var content = document.createElement("div");
        content.id  = "langSelect";
        
        for (var i=0; i<langs.length; i++) {
            if (i !== 0) {
                content.appendChild(
                        document.createTextNode(
                                Lang.msg.separator));
            }
            var lang    = langs[i];
            var a       = document.createElement("a");
            a.className     = "interwiki-" + lang.code;
            a.href          = lang.href;
            a.title         = lang.name;
            a.textContent   = lang.code;
            content.appendChild(a);
        }

        SideBar.addSimplePortlet(this.id, Lang.msg.title, content);
    }//,
};
Lang.msg = {
    title:  "Sprachen",
    
    separator:  " ",    // " · ",
};

//======================================================================
//## app/portlet/Cactions.js 

/** #p-cactions */
var Cactions = {
    id: "p-cactions",

    init: function() {
        this.unfix();
        SideBar.labelItems(Cactions.msg.labels);
        this.addPageLogTab();
        this.fixImagePageLink();
    },
    
    //------------------------------------------------------------------------------
    //## private
    
    /** add a tab with logs for the current page */
    addPageLogTab: function() {
        if (Page.namespace < 0) return;
        this.addTab('ca-logs',
                ForPage.linkLogAbout(Page.title));
    },
    
    /** bugfix: discussion pages link to action=edit without a local description page */
    fixImagePageLink: function() {
        if (Wiki.domain === "commons.wikimedia.org")    return;
        var tab = jsutil.DOM.get('ca-nstab-image');
        if (!tab)   return;
        var a   = tab.firstChild;
        if (!a.href)    return;
        a.href  = a.href.replace(/&action=edit$/, "");
    },
    
    /** move p-cactions out of column-one so it does not inherit its position:fixed */
    unfix: function() {
        var pCactions       = jsutil.DOM.get(this.id);
        var columnContent   = jsutil.DOM.get('column-content'); // belongs to the SideBar somehow..
        pCactions.parentNode.removeChild(pCactions);
        columnContent.insertBefore(pCactions, columnContent.firstChild);
    },

    /** adds a tab */
    addTab: function(id, content) {
        // ta[id] = ['g', 'Show logs for this page'];
        var li = document.createElement("li");
        li.id   = id;
        li.appendChild(content);
        var tabs    = jsutil.DOM.fetch(this.id, "ul", null, 0);
        tabs.appendChild(li);
    }//,
};
Cactions.msg = {
    labels: {
        'ca-talk':          "Diskussion",
        'ca-edit':          "Bearbeiten",
        'ca-viewsource':    "Source",
        'ca-history':       "History",
        'ca-protect':       "Schützen",
        'ca-unprotect':     "Freigeben",
        'ca-delete':        "Löschen",
        'ca-undelete':      "Entlöschen",
        'ca-move':          "Verschieben",
        
}//,
};

//======================================================================
//## app/portlet/Tools.js 

/** # p-tb */
var Tools = {
    id: 'p-tb',

    init: function() {
        SideBar.addComplexPortlet(this.id, Tools.msg.title, Tools.msg.labels, [
            this.bar1(),
            this.bar2(),
            UserBookmarks.bankView(),
            // 't-specialpages',
            [   Google.link(Page.title),
                't-permalink'//,
            ],
            this.bar3(),
            [   't-recentchangeslinked',
                't-whatlinkshere'//,
            ]
        ]);
    },
    
    //------------------------------------------------------------------------------
    
    bar1: function() {
        if (!Page.editable) return null;
        var bar = [];
        bar.push(Redirect.linkModify(Page.title));
        if (Page.deletable) {
            bar.push(FastDelete.linkDeletePopup(Page.title));
        }
        return bar.length !== 0 ? bar : null;
    },
    
    bar2: function() {
        if (!Page.editable) return null;
        return TemplatePage.bankAllPage(Page.title);
    },
    
    bar3: function() {
        // does not make sense on other wikis
        return Wiki.domain === "commons.wikimedia.org"
                ? [ 't-upload' ] : null;
    }//,
};
Tools.msg = {
    title:  "Tools",
    
    labels: {
        't-permalink':              "Perma",
        't-whatlinkshere':          "Hierher",
        't-recentchangeslinked':    "Drumrum"//,
    }//,
};

//======================================================================
//## app/portlet/Navigation.js 

/** #p-navigation */
var Navigation = {
    id: 'p-navigation',

    init: function() {
        
SideBar.addComplexPortlet(this.id, Navigation.msg.title, Navigation.msg.labels, [
            [   'n-recentchanges',
                'pt-watchlist'//,
            ],
            [   ForSite.linkNewusers(),
                ForSite.linkNewPages()//,
            ],
            
ForSite.bankProjectPages(),
            [   ForSite.linkAllLogsPopup(),
                ForSite.linkAllSpecialsPopup()//,
            ]//,
        ]);
    }//,
};
Navigation.msg = {
    title:  "Navigation",

    labels: {
        'n-recentchanges':          "Changes",
        'pt-watchlist':             "Watchlist"//,
    }//,
};

//======================================================================
//## app/portlet/Personal.js 

/** #p-personal */
var Personal = {
    // cannot use p-personal which has way too much styling
    id: 'p-personal2',

    init: function() {
        SideBar.addComplexPortlet(this.id, Personal.msg.title, Personal.msg.labels, [
            [   'pt-userpage',
                'pt-mytalk',
                ( Wiki.haveNews() ? ForUser.linkNews(Wiki.user) : null )
            ],
            [   ForUser.linkSubpages(Wiki.user),
                ForUser.linkLogsAbout(Wiki.user),
                ForUser.linkLogsActor(Wiki.user),
                'pt-mycontris'//,
            ],
            UserPage.bankGoto(),
            [   'pt-preferences',
                'pt-logout'
            ]//,
        ]);
    }//,
};
Personal.msg = {
    title:      "Persönlich",

    labels: {
        'pt-mytalk':        "Diskussion",
        'pt-mycontris':     "Contribs",
        'pt-preferences':   "Prefs",
        'pt-logout':        "Logout"//,
    }//,
};

//======================================================================
//## app/portlet/Communication.js 

/** #p-communication: communication with Page.owner */
var Communication = {
    id: 'p-communication',

    init: function() {
        if (!Page.owner)                return;
        if (Page.owner === Wiki.user)   return;
        // TODO display shadowed
        if (!Page.ownerExists)          return;

        var ipOwner = IP.isV4(Page.owner);

        SideBar.addComplexPortlet(this.id, Communication.msg.title, {}, [
            TemplateTalk.bankOfficial(Page.owner),
            TemplateTalk.bankPersonal(Page.owner),
            [   ForUser.linkHome(Page.owner),
                ForUser.linkTalk(Page.owner)//,
            ],
            [   ForUser.linkSubpages(Page.owner),
                ForUser.linkLogsAbout(Page.owner),
                ForUser.linkLogsActor(Page.owner),
                ForUser.linkContribs(Page.owner)//,
            ],
            !ipOwner ? null : ForUser.bankIP(Page.owner),
            ipOwner ? null :
            [   ForUser.linkEmail(Page.owner)//,
            ],
            [   ForUser.linkBlock(Page.owner),
            ],
        ]);
    }//,
};
Communication.msg = {
    title:  "Kommunikation"//,
};

//======================================================================
//## main.js 

/** onload hook */
function initialize() {
    try {
        // user configuration
        if (typeof configure === "function")    configure();
    
        // init features
        Wiki.init();
        Page.init();
        
        ForSite.init();
        TemplateTalk.init();
        
        // init extensions
        FastWatch.init();
        FastRestore.init();
        FastRollback.init();
        UserRevert.init();
        ActionHistory.init();
        ActionDiff.init();
        SpecialAny.init();
        EditWarning.init();
        SectionEdit.init();
    
        // build new portlets
        Search.init();
        Cactions.init();
        Tools.init();
        Navigation.init();
        Personal.init();
        Communication.init();
        
// TODO ugly hacks:
        // move the sister projects, export and language portlets downwards
        var sisterprojects  = jsutil.DOM.get("p-sisterprojects");
        if (sisterprojects) {
            jsutil.DOM.removeNode(sisterprojects);
            SideBar.preparedPortlets.push(sisterprojects);
        }
        var collPrintExport = jsutil.DOM.get("p-coll-print_export");
        if (collPrintExport) {
            jsutil.DOM.removeNode(collPrintExport);
            SideBar.preparedPortlets.push(collPrintExport);
        }
        var pLang   = jsutil.DOM.get("p-lang");
        if (pLang) {
            jsutil.DOM.removeNode(pLang);
            SideBar.preparedPortlets.push(pLang);
        }
        
        // display portlets created before
        SideBar.showPortlets();
        
        // insert sitename header
        SideBar.insertSiteName();
        
    
    }
    catch (e) {
        if (window.console) console.error("cannot initialize", e, e.stack);
    }
}


$(document).ready(initialize);

/* 

*/