MediaWiki:LAPI.js

Материал из Викиучебника — открытых книг для открытого мира

Замечание: Возможно, после публикации вам придётся очистить кэш своего браузера, чтобы увидеть изменения.

  • Firefox / Safari: Удерживая клавишу Shift, нажмите на панели инструментов Обновить либо нажмите Ctrl+F5 или Ctrl+R (⌘+R на Mac)
  • Google Chrome: Нажмите Ctrl+Shift+R (⌘+Shift+R на Mac)
  • Internet Explorer / Edge: Удерживая Ctrl, нажмите Обновить либо нажмите Ctrl+F5
  • Opera: Нажмите Ctrl+F5.
// <source lang=javascript">

/*
  Small JS library containing stuff I use often.
  
  Author: [[User:Lupo]], June 2009
  License: Quadruple licensed GFDL, GPL, LGPL and Creative Commons Attribution 3.0 (CC-BY-3.0)
 
  Choose whichever license of these you like best :-)

  Includes the following components:
   - Object enhancements (clone, merge)
   - String enhancements (trim, ...)
   - Array enhancements (JS 1.6)
   - Function enhancements (bind)
   - LAPI            Most basic DOM functions: $ (getElementById), make
   -   LAPI.Ajax     Ajax request implementation, tailored for MediaWiki/WMF sites
   -   LAPI.Browser  Browser detection (general)
   -   LAPI.DOM      DOM helpers, including a cross-browser DOM parser
   -   LAPI.WP       MediaWiki/WMF-specific DOM routines
   -   LAPI.Edit     Simple editor implementation with save, cancel, preview (for WMF sites)
   -   LAPI.Evt      Event handler routines (general)
   -   LAPI.Pos      Position calculations (general)
*/

// Global: importScript (from wiki.js, for MediaWiki:AjaxSubmit.js)

// Configuration: set this to the URL of your image server. The value is a string representation
// of a regular expression. For instance, for Wikia, use "http://images\\d\\.wikia\\.nocookie\\.net".
// Remember to double-escape the backslash.
if (typeof (LAPI_file_store) == 'undefined')
  var LAPI_file_store = "(https?:)?//upload\\.wikimedia\\.org/";

// Some basic routines, mainly enhancements of the String, Array, and Function objects.
// Some taken from Javascript 1.6, some own.

/** Object enhancements ************/

// Note: adding these to the prototype may break other code that assumes that
// {} has no properties at all.
Object.clone = function (source, includeInherited)
{
  if (!source) return null;
  var result = {};
  for (var key in source) {
    if (includeInherited || source.hasOwnProperty (key)) result[key] = source[key];
  }
  return result;
};

Object.merge = function (from, into, includeInherited)
{
  if (!from) return into;
  for (var key in from) {
    if (includeInherited || from.hasOwnProperty (key)) into[key] = from[key];
  }
  return into;
};

Object.mergeSome = function (from, into, includeInherited, predicate)
{
  if (!from) return into;
  if (typeof (predicate) == 'undefined')
    return Object.merge (from, into, includeInherited);
  for (var key in from) {
    if ((includeInherited || from.hasOwnProperty (key)) && predicate (from, into, key))
      into[key] = from[key];
  }
  return into;
};

Object.mergeSet = function (from, into, includeInherited)
{
  return Object.mergeSome
           (from, into, includeInherited, function (src, tgt, key) {return src[key] != null;});
}

/** String enhancements (Javascript 1.6) ************/

// Removes given characters from both ends of the string.
// If no characters are given, defaults to removing whitespace.
if (!String.prototype.trim) {
  String.prototype.trim = function (chars) {
    if (!chars) return this.replace (/^\s+|\s+$/g, "");
    return this.trimRight (chars).trimLeft (chars);
  };
}

// Removes given characters from the beginning of the string.
// If no characters are given, defaults to removing whitespace.
if (!String.prototype.trimLeft) {
  String.prototype.trimLeft = function (chars) {
    if (!chars) return this.replace (/^\s\s*/, "");
    return this.replace (new RegExp ('^[' + chars.escapeRE () + ']+'), "");
  };
}
String.prototype.trimFront = String.prototype.trimLeft; // Synonym

// Removes given characters from the end of the string.
// If no characters are given, defaults to removing whitespace.
if (!String.prototype.trimRight) {
  String.prototype.trimRight = function (chars) {
    if (!chars) return this.replace (/\s\s*$/, "");
    return this.replace (new RegExp ('[' + chars.escapeRE () + ']+$'), "");
  };
}
String.prototype.trimEnd = String.prototype.trimRight; // Synonym

/** Further String enhancements ************/

// Returns true if the string begins with prefix.
String.prototype.startsWith = function (prefix) {
  return this.indexOf (prefix) == 0;
};

// Returns true if the string ends in suffix
String.prototype.endsWith = function (suffix) {
  return this.lastIndexOf (suffix) + suffix.length == this.length;
};

// Returns true if the string contains s.
String.prototype.contains = function (s) {
  return this.indexOf (s) >= 0;
};

// Replace all occurrences of a string pattern by replacement.
String.prototype.replaceAll = function (pattern, replacement) {
  return this.split (pattern).join (replacement);
};

// Escape all backslashes and single or double quotes such that the result can
// be used in Javascript inside quotes or double quotes.
String.prototype.stringifyJS = function () {
  return this.replace (/([\\\'\"]|%5C|%27|%22)/g, '\\$1') // ' // Fix syntax coloring
             .replace (/\n/g, '\\n');
}

// Escape all RegExp special characters such that the result can be safely used
// in a RegExp as a literal.
String.prototype.escapeRE = function () {
  return this.replace (/([\\{}()|.?*+^$\[\]])/g, "\\$1");
};

String.prototype.escapeXML = function (quot, apos) {
  var s    = this.replace (/&/g,    '&amp;')
                 .replace (/\xa0/g, '&nbsp;')
                 .replace (/</g,    '&lt;')
                 .replace (/>/g,    '&gt;');
  if (quot) s = s.replace (/\"/g,   '&quot;'); // " // Fix syntax coloring
  if (apos) s = s.replace (/\'/g,   '&apos;'); // ' // Fix syntax coloring
  return s;
};

String.prototype.decodeXML = function () {
  return this.replace(/&quot;/g, '"')
             .replace(/&apos;/g, "'")
             .replace(/&gt;/g,   '>')
             .replace(/&lt;/g,   '<')
             .replace(/&nbsp;/g, '\xa0')
             .replace(/&amp;/g,  '&');
};

String.prototype.capitalizeFirst = function () {
  return this.substring (0, 1).toUpperCase() + this.substring (1);
};

String.prototype.lowercaseFirst = function () {
  return this.substring (0, 1).toLowerCase() + this.substring (1);
};

// This is actually a function on URLs, but since URLs typically are strings in
// Javascript, let's include this one here, too.
String.prototype.getParamValue = function (param) {
  var re = new RegExp ('[&?]' + param.escapeRE () + '=([^&#]*)');
  var m  = re.exec (this);
  if (m && m.length >= 2) return decodeURIComponent (m[1]);
  return null;
};

String.getParamValue = function (param, url)
{
  if (typeof (url) == 'undefined' || url === null) url = document.location.href;
  try {
    return url.getParamValue (param);
  } catch (e) { 
    return null;
  }
};

/** Function enhancements ************/

if (!Function.prototype.bind) {
  // Return a function that calls the function with 'this' bound to 'thisObject'
  Function.prototype.bind = function (thisObject) {
    var f = this, obj = thisObject, slice = Array.prototype.slice, prefixedArgs = slice.call (arguments, 1);
    return function () { return f.apply (obj, prefixedArgs.concat (slice.call (arguments))); };
  };
}

/** Array enhancements (Javascript 1.6) ************/

// Note that contrary to JS 1.6, we treat the thisObject as optional.
// Don't add to the prototype, that would break for (var key in array) loops!

// Returns a new array containing only those elements for which predicate
// is true.
if (!Array.filter) {
  Array.filter = function (target, predicate, thisObject)
  {
    if (target === null) return null;
    if (typeof (target.filter) == 'function') return target.filter (predicate, thisObject);
    if (typeof (predicate) != 'function')
      throw new Error ('Array.filter: predicate must be a function');
    var l = target.length;
    var result = [];
    if (thisObject) predicate = predicate.bind (thisObject);
    for (var i=0; l && i < l; i++) {
      if (i in target) {
        var curr = target[i];
        if (predicate (curr, i, target)) result[result.length] = curr;
      }
    }
    return result;
  };
}
Array.select = Array.filter; // Synonym

// Calls iterator on all elements of the array
if (!Array.forEach) {
  Array.forEach = function (target, iterator, thisObject)
  {
    if (target === null) return;
    if (typeof (target.forEach) == 'function') {
      target.forEach (iterator, thisObject);
      return;
    }
    if (typeof (iterator) != 'function')
      throw new Error ('Array.forEach: iterator must be a function');
    var l = target.length;
    if (thisObject) iterator = iterator.bind (thisObject);
    for (var i=0; l && i < l; i++) {
      if (i in target) iterator (target[i], i, target);
    }
  };
}

// Returns true if predicate is true for every element of the array, false otherwise
if (!Array.every) {
  Array.every = function (target, predicate, thisObject)
  {
    if (target === null) return true;
    if (typeof (target.every) == 'function') return target.every (predicate, thisObject);
    if (typeof (predicate) != 'function')
      throw new Error ('Array.every: predicate must be a function');
    var l = target.length;
    if (thisObject) predicate = predicate.bind (thisObject);
    for (var i=0; l && i < l; i++) {
      if (i in target && !predicate (target[i], i, target)) return false;
    }
    return true;
  };
}
Array.forAll = Array.every; // Synonym

// Returns true if predicate is true for at least one element of the array, false otherwise.
if (!Array.some) {
  Array.some = function (target, predicate, thisObject)
  {
    if (target === null) return false;
    if (typeof (target.some) == 'function') return target.some (predicate, thisObject);
    if (typeof (predicate) != 'function')
      throw new Error ('Array.some: predicate must be a function');
    var l = target.length;
    if (thisObject) predicate = predicate.bind (thisObject);
    for (var i=0; l && i < l; i++) {
      if (i in target && predicate (target[i], i, target)) return true;
    }
    return false;
  };
}
Array.exists = Array.some; // Synonym

// Returns a new array built by applying mapper to all elements.
if (!Array.map) {
  Array.map = function (target, mapper, thisObject)
  {
    if (target === null) return null;
    if (typeof (target.map) == 'function') return target.map (mapper, thisObject);
    if (typeof (mapper) != 'function')
      throw new Error ('Array.map: mapper must be a function');
    var l = target.length;
    var result = [];
    if (thisObject) mapper = mapper.bind (thisObject);
    for (var i=0; l && i < l; i++) {
      if (i in target) result[i] = mapper (target[i], i, target);
    }
    return result;
  };
}

if (!Array.indexOf) {
  Array.indexOf = function (target, elem, from)
  {
    if (target === null) return -1;
    if (typeof (target.indexOf) == 'function') return target.indexOf (elem, from);
    if (typeof (target.length) == 'undefined') return -1;
    var l = target.length;    
    if (isNaN (from)) from = 0; else from = from || 0;
    from = (from < 0) ? Math.ceil (from) : Math.floor (from);
    if (from < 0) from += l;
    if (from < 0) from = 0;
    while (from < l) {
      if (from in target && target[from] === elem) return from;
      from += 1;
    }
    return -1;
  };
}

if (!Array.lastIndexOf) {
  Array.lastIndexOf = function (target, elem, from)
  {
    if (target === null) return -1;
    if (typeof (target.lastIndexOf) == 'function') return target.lastIndexOf (elem, from);
    if (typeof (target.length) == 'undefined') return -1;
    var l = target.length;
    if (isNaN (from)) from = l-1; else from = from || (l-1);
    from = (from < 0) ? Math.ceil (from) : Math.floor (from);
    if (from < 0) from += l; else if (from >= l) from = l-1;
    while (from >= 0) {
      if (from in target && target[from] === elem) return from;
      from -= 1;
    }
    return -1;
  };
}

/** Additional Array enhancements ************/

Array.remove = function (target, elem) {
  var i = Array.indexOf (target, elem);
  if (i >= 0) target.splice (i, 1);
};

Array.contains = function (target, elem) {
  return Array.indexOf (target, elem) >= 0;
};

Array.flatten = function (target) {
  var result = [];
  Array.forEach (target, function (elem) {result = result.concat (elem);});
  return result;
};

// Calls selector on the array elements until it returns a non-null object
// and then returns that object. If selector always returns null, any also
// returns null. See also Array.map.
Array.any = function (target, selector, thisObject)
{
  if (target === null) return null;
  if (typeof (selector) != 'function')
    throw new Error ('Array.any: selector must be a function');
  var l = target.length;
  var result = null;
  if (thisObject) selector = selector.bind (thisObject);
  for (var i=0; l && i < l; i++) {
    if (i in target) {
      result = selector (target[i], i, target);
      if (result != null) return result;
    }
  }
  return null;
};

// Return a contiguous array of the contents of source, which may be an array or pseudo-array,
// basically anything that has a length and can be indexed. (E.g. live HTMLCollections, but also
// Strings, or objects, or the arguments "variable".
Array.make = function (source)
{
  if (!source || typeof (source.length) == 'undefined') return null;
  var result = [];
  var l      = source.length;
  for (var i=0; i < l; i++) {
    if (i in source) result[result.length] = source[i];
  }
  return result;
};

if (typeof (window.LAPI) == 'undefined') {

window.LAPI = {
  Ajax :
  {
    getRequest : function ()
    {
      var request = null;
      try {
        request = new XMLHttpRequest();
      } catch (anything) {
        request = null;
        if (!!window.ActiveXObject) {
          if (typeof (LAPI.Ajax.getRequest.msXMLHttpID) == 'undefined') {
            var XHR_ids = [  'MSXML2.XMLHTTP.6.0', 'MSXML2.XMLHTTP.3.0'
                           , 'MSXML2.XMLHTTP', 'Microsoft.XMLHTTP'
                          ];
            for (var i=0; i < XHR_ids.length && !request; i++) {
              try {
                request = new ActiveXObject (XHR_ids[i]);
                if (request) LAPI.Ajax.getRequest.msXMLHttpID = XHR_ids[i];
              } catch (ex) {
                request = null;
              }
            }
            if (!request) LAPI.Ajax.getRequest.msXMLHttpID = null;
          } else if (LAPI.Ajax.getRequest.msXMLHttpID) {
            request = new ActiveXObject (LAPI.Ajax.getRequest.msXMLHttpID);
          }
        } // end if IE
      } // end try-catch
      return request;
    }
  },

  $ : function (selector, doc, multi)
  {
    if (!selector || selector.length == 0) return null;
    doc = doc || document;
    if (typeof (selector) == 'string') {
      if (selector.charAt (0) == '#') selector = selector.substring (1);
      if (selector.length > 0) return doc.getElementById (selector);
      return null;
    } else {
      if (multi) return Array.map (selector, function (id) {return LAPI.$ (id, doc);});
      return Array.any (selector, function (id) {return LAPI.$ (id, doc);});
    }
  },

  make : function (tag, attribs, css, doc)
  {
    doc = doc || document;
    if (!tag || tag.length == 0) throw new Error ('No tag for LAPI.make');
    var result = doc.createElement (tag);
    Object.mergeSet (attribs, result);
    Object.mergeSet (css, result.style);
    if (/^(form|input|button|select|textarea)$/.test (tag) &&
        result.id && result.id.length > 0 && !result.name
       )
    {
      result.name = result.id;
    }
    return result;
  },

  formatException : function (ex, asDOM)
  {
    var name = ex.name || "";
    var msg  = ex.message || "";
    var file = null;
    var line = null;
    if (msg && msg.length > 0 && msg.charAt (0) == '#') {
      // User msg: don't confuse users with error locations. (Note: could also use
      // custom exception types, but that doesn't work right on IE6.)
      msg = msg.substring (1);
    } else {
      file = ex.fileName || ex.sourceURL || null; // Gecko, Webkit, others
      line = ex.lineNumber || ex.line || null;    // Gecko, Webkit, others
    }
    if (name || msg) {
      if (!asDOM) {
        return
          'Exception ' + name + ': ' + msg
          + (file ? '\nFile ' + file + (line ? ' (' + line + ')' : "") : "")
          ;
      } else {
        var ex_msg = LAPI.make ('div');
        ex_msg.appendChild (document.createTextNode ('Exception ' + name + ': ' + msg));
        if (file) {
          ex_msg.appendChild (LAPI.make ('br'));
          ex_msg.appendChild
            (document.createTextNode ('File ' + file + (line ? ' (' + line + ')' : "")));
        }
        return ex_msg;
      }
    } else {
      return null;
    }
  }

};

} // end if (guard)

if (typeof (LAPI.Browser) == 'undefined') {
  
// Yes, usually it's better to test for available features. But sometimes there's no
// way around testing for specific browsers (differences in dimensions, layout errors,
// etc.)
LAPI.Browser =
(function (agent) {
  var result = {};
  result.client = agent;
  var m = agent.match(/applewebkit\/(\d+)/);
  result.is_webkit = (m != null);
  result.is_safari = result.is_webkit && !agent.contains ('spoofer');
  result.webkit_version = (m ? parseInt (m[1]) : 0);
  result.is_khtml =
       navigator.vendor == 'KDE'
    || (document.childNodes && !document.all && !navigator.taintEnabled && navigator.accentColorName);
  result.is_gecko =
       agent.contains ('gecko')
    && !/khtml|spoofer|netscape\/7\.0/.test (agent);
  result.is_ff_1    = agent.contains ('firefox/1');
  result.is_ff_2    = agent.contains ('firefox/2');
  result.is_ff_ge_2 = /firefox\/[2-9]|minefield\/3/.test (agent);
  result.is_ie      = agent.contains ('msie') || !!window.ActiveXObject;
  result.is_ie_lt_7 = false;
  if (result.is_ie) {
    var version = /msie ((\d|\.)+)/.exec (agent);
    result.is_ie_lt_7  = (version != null && (parseFloat(version[1]) < 7));
  }
  result.is_opera      = agent.contains ('opera');
  result.is_opera_ge_9 = false;
  result.is_opera_95   = false;
  if (result.is_opera) {
    m = /opera\/((\d|\.)+)/.exec (agent);
    result.is_opera_95   = m && (parseFloat (m[1]) >= 9.5);
    result.is_opera_ge_9 = m && (parseFloat (m[1]) >= 9.0);
  }
  result.is_mac = agent.contains ('mac');
  return result;
})(navigator.userAgent.toLowerCase ());

} // end if (guard)

if (typeof (LAPI.DOM) == 'undefined') {
  
LAPI.DOM =
{
  // IE6 doesn't have these Node constants in Node, so put them here
  ELEMENT_NODE                :  1,
  ATTRIBUTE_NODE              :  2,
  TEXT_NODE                   :  3,
  CDATA_SECTION_NODE          :  4,
  ENTITY_REFERENCE_NODE       :  5,
  ENTITY_NODE                 :  6,
  PROCESSING_INSTRUCTION_NODE :  7,
  COMMENT_NODE                :  8,
  DOCUMENT_NODE               :  9,
  DOCUMENT_TYPE_NODE          : 10,
  DOCUMENT_FRAGMENT_NODE      : 11,
  NOTATION_NODE               : 12,

  cleanAttributeName : function (attr_name)
  {
    if (!LAPI.Browser.is_ie) return attr_name;
    if (!LAPI.DOM.cleanAttributeName._names) {
      LAPI.DOM.cleanAttributeName._names = {
         'class'       : 'className'
        ,'cellspacing' : 'cellSpacing'
        ,'cellpadding' : 'cellPadding'
        ,'colspan'     : 'colSpan'
        ,'maxlength'   : 'maxLength'
        ,'readonly'    : 'readOnly'
        ,'rowspan'     : 'rowSpan'
        ,'tabindex'    : 'tabIndex'
        ,'valign'      : 'vAlign'
      };
    }
    var cleaned = attr_name.toLowerCase ();
    return LAPI.DOM.cleanAttributeName._names[cleaned] || cleaned;
  },

  importNode : function (into, node, deep)
  {
    if (!node) return null;
    if (into.importNode) return into.importNode (node, deep);
    if (node.ownerDocument == into) return node.cloneNode (deep);
    var new_node = null;
    switch (node.nodeType) {
      case LAPI.DOM.ELEMENT_NODE :
        new_node = into.createElement (node.nodeName);
        Array.forEach (
            node.attributes
          , function (attr) {
              if (attr && attr.nodeValue && attr.nodeValue.length > 0)
                new_node.setAttribute (LAPI.DOM.cleanAttributeName (attr.name), attr.nodeValue);
            }
        );
        new_node.style.cssText = node.style.cssText;
        if (deep) {
          Array.forEach (
              node.childNodes
            , function (child) {
                var copy = LAPI.DOM.importNode (into, child, true);
                if (copy) new_node.appendChild (copy);
              }
          );
        }
        return new_node;
      case LAPI.DOM.TEXT_NODE :
        return into.createTextNode (node.nodeValue);
      case LAPI.DOM.CDATA_SECTION_NODE :
        return (into.createCDATASection
                  ? into.createCDATASection (node.nodeValue)
                  : into.createTextNode (node.nodeValue)
               );
      case LAPI.DOM.COMMENT_NODE :
        return into.createComment (node.nodeValue);
      default :
        return null;
    } // end switch
  },

  parse : function (str, content_type)
  {
    function getDocument (str, content_type)
    {
      if (typeof (DOMParser) != 'undefined') {
        var parser = new DOMParser ();
        if (parser && parser.parseFromString)
          return parser.parseFromString (str, content_type);
      }
      // We don't have DOMParser
      if (LAPI.Browser.is_ie) {
        var doc = null;
        // Apparently, these can be installed side-by-side. Try to get the newest one available.
        // Unfortunately, one finds a variety of version strings on the net. I have no idea which
        // ones are correct.
        if (typeof (LAPI.DOM.parse.msDOMDocumentID) == 'undefined') {
          // If we find a parser, we cache it. If we cannot find one, we also remember that.
          var parsers =
            [ 'MSXML6.DOMDocument','MSXML5.DOMDocument','MSXML4.DOMDocument','MSXML3.DOMDocument'
             ,'MSXML2.DOMDocument.5.0','MSXML2.DOMDocument.4.0','MSXML2.DOMDocument.3.0'
             ,'MSXML2.DOMDocument','MSXML.DomDocument','Microsoft.XmlDom'];
          for (var i=0; i < parsers.length && !doc; i++) {
            try {
              doc = new ActiveXObject (parsers[i]);
              if (doc) LAPI.DOM.parse.msDOMDocumentID = parsers[i];
            } catch (ex) {
              doc = null;
            }
          }
          if (!doc) LAPI.DOM.parse.msDOMDocumentID = null;
        } else if (LAPI.DOM.parse.msDOMDocumentID) {
          doc = new ActiveXObject (LAPI.DOM.parse.msDOMDocumentID);
        }
        if (doc) {
          doc.async = false;
          doc.loadXML (str);
          return doc;
        }
      } 
      // Try using a "data" URI (http://www.ietf.org/rfc/rfc2397). Reported to work on
      // older Safaris.
      content_type  = content_type || 'application/xml';
      var req = LAPI.Ajax.getRequest ();
      if (req) {
        // Synchronous is OK, since "data" URIs are local
        req.open
          ('GET', 'data:' + content_type + ';charset=utf-8,' + encodeURIComponent (str), false);
        if (req.overrideMimeType) req.overrideMimeType (content_type);
        req.send (null);
        return req.responseXML;
      }
      return null;
    } // end getDocument

    var doc = null;

    try {
      doc = getDocument (str, content_type);
    } catch (ex) {
      doc = null;
    }
    if (   (    (!doc || !doc.documentElement)
             && (   str.search (/^\s*(<xml[^>]*>\s*)?<!doctype\s+html/i) >= 0
                 || str.search (/^\s*<html/i) >= 0
                )
           )
        ||
           (doc && (   LAPI.Browser.is_ie
                    && (!doc.documentElement
                        && doc.parseError && doc.parseError.errorCode != 0
                        && doc.parseError.reason.contains ('Error processing resource')
                        && doc.parseError.reason.contains
                             ('http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd')
                       )
                   )
           )
       )
    {
      // Either the text specified an (X)HTML document, but we failed to get a Document, or we
      // hit the walls of the single-origin policy on IE which tries to get the DTD from the
      // URI specified... Let's fake a document:
      doc = LAPI.DOM.fakeHTMLDocument (str);
    }
    return doc;
  },

  parseHTML : function (str, sanity_check)
  {
    // Always use a faked document; parsing as XML and then treating the result as HTML doesn't work right with HTML5.
    return LAPI.DOM.fakeHTMLDocument (str);
  },

  fakeHTMLDocument : function (str)
  {
    var body_tag = /<body.*?>/.exec (str);
    if (!body_tag || body_tag.length == 0) return null;
    body_tag = body_tag.index + body_tag[0].length; // Index after the opening body tag
    var body_end = str.lastIndexOf ('</body>');
    if (body_end < 0) return null;
    var content = str.substring (body_tag, body_end); // Anything in between          
    content = content.replace(/<script(.|\s)*?\/script>/g, ""); // Sanitize: strip scripts
    return new LAPI.DOM.DocumentFacade (content);
  },

  isValid : function (doc)
  {
    if (!doc) return doc;
    if (typeof (doc.parseError) != 'undefined') { // IE
      if (doc.parseError.errorCode != 0) {
        throw new Error (  'XML parse error: ' + doc.parseError.reason
                         + ' line ' + doc.parseError.line
                         + ' col ' + doc.parseError.linepos
                         + '\nsrc = ' + doc.parseError.srcText);
      }
    } else {
      // FF... others?
      var root = doc.documentElement;
      if (/^parsererror$/i.test (root.tagName)) {
        throw new Error ('XML parse error: ' + root.getInnerText ());
      }
    }
    return doc;
  },

  hasClass : function (node, className)
  {
    if (!node) return false;
    return (' ' + node.className + ' ').contains (' ' + className + ' ');
  },
  
  setContent : function (node, content)
  {
    if (content == null) return node;
    LAPI.DOM.removeChildren (node);
    if (content.nodeName) { // presumably a DOM tree, like a span or a document fragment
      node.appendChild (content);
    } else if (typeof (node.innerHTML) != 'undefined') {
      node.innerHTML = content.toString ();
    } else {
      node.appendChild (document.createTextNode (content.toString ()));
    }
    return node;
  },

  makeImage : function (src, width, height, title, doc)
  {
    return LAPI.make (
               'img'
             , {src : src, width: "" + width, height : "" + height, title : title}
             , doc
           );
  },

  makeButton : function (id, text, f, submit, doc)
  {
    return LAPI.make (
               'input'
             , {id : id || "", type: (submit ? 'submit' : 'button'), value: text, onclick: f}
             , doc
           );
  },
  
  makeLabel : function (id, text, for_elem, doc)
  {
    var label = LAPI.make ('label', {id: id || "", htmlFor: for_elem}, null, doc);
    return LAPI.DOM.setContent (label, text);
  },
  
  makeLink : function (url, text, tooltip, onclick, doc)
  {
    var lk = LAPI.make ('a', {href: url, title: tooltip, onclick: onclick}, null, doc);
    return LAPI.DOM.setContent (lk, text || url);
  },
  
  // Unfortunately, extending Node.prototype may not work on some browsers,
  // most notably (you've guessed it) IE...
  
  getInnerText : function (node)
  {
    if (node.textContent) return node.textContent;
    if (node.innerText)   return node.innerText;
    var result = "";
    if (node.nodeType == LAPI.DOM.TEXT_NODE) {
      result = node.nodeValue;
    } else {
      Array.forEach (node.childNodes,
        function (elem) {
          switch (elem.nodeType) {
            case LAPI.DOM.ELEMENT_NODE:
              result += LAPI.DOM.getInnerText (elem);
              break;
            case LAPI.DOM.TEXT_NODE:
              result += elem.nodeValue;
              break;
          }
        }
      );
    }
    return result;
  },
  
  removeNode : function (node)
  {
    if (node.parentNode) node.parentNode.removeChild (node);
    return node;
  },

  removeChildren : function (node)
  {
    // if (typeof (node.innerHTML) != 'undefined') node.innerHTML = "";
    // Not a good idea. On IE this destroys all contained nodes, even if they're still referenced
    // from JavaScript! Can't have that...
    while (node.firstChild) node.removeChild (node.firstChild);
    return node;
  },

  insertNode : function (node, before)
  {
    before.parentNode.insertBefore (node, before);
    return node;
  },

  insertAfter : function (node, after)
  {
    var next = after.nextSibling;
    after.parentNode.insertBefore (node, next);
    return node;
  },
  
  replaceNode : function (node, newNode)
  {
    node.parentNode.replaceChild (node, newNode);
    return newNode;
  },
  
  isParentOf : function (parent, child)
  {
    while (child && child != parent && child.parentNode) child = child.parentNode;
    return child == parent;
  },

  // Property is to be in CSS style, e.g. 'background-color', not in JS style ('backgroundColor')!
  // Use standard 'cssFloat' for float property.
  currentStyle : function (element, property)
  {
    function normalize (prop) {
      // Don't use a regexp with a lambda function (available only in JS 1.3)... and I once had a
      // case where IE6 goofed grossly with a lambda function. Since then I try to avoid those
      // (though they're neat).
      if (prop == 'cssFloat') return 'styleFloat'; // We'll try both variants below, standard first...
      var result = prop.split ('-');
      result =
        Array.map (result, function (s) { if (s) return s.capitalizeFirst (); else return s;});
      result = result.join ("");
      return result.lowercaseFirst ();
    }

    if (element.ownerDocument.defaultView
        && element.ownerDocument.defaultView.getComputedStyle)
    { // Gecko etc.
      if (property == 'cssFloat') property = 'float';
      return
        element.ownerDocument.defaultView.getComputedStyle (element, null).getPropertyValue (property);
    } else {
      var result;
      if (element.currentStyle) { // IE, has subtle differences to getComputedStyle
        result = element.currentStyle[property] || element.currentStyle[normalize (property)];
      } else // Not exactly right, but best effort
        result = element.style[property] || element.style[normalize (property)];
      // Convert em etc. to pixels. Kudos to Dean Edwards; see
      // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291
      if (!/^\d+(px)?$/i.test (result) && /^\d/.test (result) && element.runtimeStyle) {
        var style                 = element.style.left;
        var runtimeStyle          = element.runtimeStyle.left;
        element.runtimeStyle.left = element.currentStyle.left;
        element.style.left        = result || 0;
        result = elem.style.pixelLeft + "px";
        element.style.left        = style;
        element.runtimeStyle.left = runtimeStyle;
      }
    }
  },

  // Load a given image in a given size. Parameters:
  //   title
  //     Full title of the image, including the "File:" namespace
  //   url
  //     If != null, URL of an existing thumb for that image. If width is null, may contain the url
  //     of the full image.
  //   width
  //     If != null, desired width of the image, otherwise load the full image
  //   height
  //     If width != null, height should also be set.
  //   auto_thumbs
  //     True if missing thumbnails are generated automatically.
  //   success
  //     Function to be called once the image is loaded. Takes one parameter: the IMG-tag of
  //     the loaded image
  //   failure
  //     Function to be called if the image cannot be loaded. Takes one parameter: a string
  //     containing an error message.
  loadImage : function (title, url, width, height, auto_thumbs, success, failure)
  {
    if (auto_thumbs && url) {
      // MediaWiki-style with 404 handler. Set condition to false if your wiki does not have such a
      // setup.
      var img_src = null;
      if (width) {
        var i = url.lastIndexOf ('/');
        if (i >= 0) {
          img_src = url.substring (0, i)
                  + url.substring (i).replace (/^\/\d+px-/, '/' + width + 'px-');
        }
      } else if (url) {
        img_src = url;
      }
      if (!img_src) {
        failure ("Cannot load image from url " + url);
        return;
      }
      var img_loader =
        LAPI.make (
            'img'
          , {src: img_src}
          , {position: 'absolute', top: '0px', left: '0px', display: 'none'}
        );
      if (width) img_loader.width = "" + width;
      if (height) img_loader.height = "" + height;
      LAPI.Evt.attach (img_loader, 'load', function () {success (img_loader);});
      document.body.appendChild (img_loader); // Now the browser goes loading the image
    } else {
      // No url to work with. Use parseWikitext to have a thumb generated an to get its URL.
      LAPI.Ajax.parseWikitext (
          '[[' + title + (width ? '|' + width + 'px' : "") + ']]'
        , function (html, failureFunc) {
            var dummy =
              LAPI.make (
                  'div'
                , null
                , {position: 'absolute', top: '0px', left: '0px', display: 'none'}
              );
            document.body.appendChild (dummy); // Now start loading the image
            dummy.innerHTML = html;
            var imgs = dummy.getElementsByTagName ('img');
            LAPI.Evt.attach (
                imgs[0], 'load'
              , function () {
                  success (imgs[0]);
                  LAPI.DOM.removeNode (dummy);
                }
            );
          }
        , function (request, json_result)
          {
            failure ("Image loading failed: " + request.status + ' ' + request.statusText);
          }
        , false // Not as preview
        , null  // user language: don't care
        , null  // on page: don't care
        , 3600  // Cache for an hour
      );
    }
  }

}; // end LAPI.DOM

LAPI.DOM.DocumentFacade = function () {this.initialize.apply (this, arguments);};

LAPI.DOM.DocumentFacade.prototype =
{
  initialize : function (text)
  {
    // It's not a real document, but it will behave like one for our purposes.
    this.documentElement = LAPI.make ('div', null, {display: 'none', position: 'absolute'});
    this.body = LAPI.make ('div', null, {position: 'relative'});
    this.documentElement.appendChild (this.body);
    document.body.appendChild (this.documentElement);
    this.body.innerHTML = text;
    // Find all forms
    var forms = document.getElementsByTagName ('form');
    var self = this;
    this.forms = Array.select (forms, function (f) {return LAPI.DOM.isParentOf (self.body, f);});
    // Konqueror 4.2.3/4.2.4 clears form.elements when the containing div is removed from the
    // parent document?!
    if (!LAPI.Browser.is_khtml) {
      LAPI.DOM.removeNode (this.documentElement);
    } else {
      this.dispose = function () {LAPI.DOM.removeNode (this.documentElement);};
      // Since we must leave the stuff *in* the original document on Konqueror, we'll also need a
      // dispose routine... what an ugly hack.
    }
    this.allIDs = {};
    this.isFake = true;
  },

  createElement : function (tag) { return document.createElement (tag); },
  createDocumentFragment : function () { return document.createDocumentFragment (); },
  createTextNode : function (text) { return document.createTextNode (text); },
  createComment : function (text) { return document.createComment (text); },
  createCDATASection : function (text) { return document.createCDATASection (text); },
  createAttribute : function (name) { return document.createAttribute (name); },
  createEntityReference : function (name) { return document.createEntityReference (name); },
  createProcessingInstruction : function (target, data) { return document.createProcessingInstruction (target, data); },
  
  getElementsByTagName : function (tag)
  {
    // Grossly inefficient, but deprecated anyway
    var res = [];
    function traverse (node, tag)
    {
      if (node.nodeName.toLowerCase () == tag) res[res.length] = node;
      var curr = node.firstChild;
      while (curr) { traverse (curr, tag); curr = curr.nextSibling; }
    }
    traverse (this.body, tag.toLowerCase ());
    return res;
  },

  getElementById : function (id)
  {
    function traverse (elem, id)
    {
      if (elem.id == id) return elem;
      var res  = null;
      var curr = elem.firstChild;
      while (curr && !res) {
        res = traverse (curr, id);
        curr = curr.nextSibling;
      }
      return res;
    }
    
    if (!this.allIDs[id]) this.allIDs[id] = traverse (this.body, id);
    return this.allIDs[id];
  }

  // ...NS operations omitted

}; // end DocumentFacade

if (document.importNode) {
  LAPI.DOM.DocumentFacade.prototype.importNode =
    function (node, deep) { document.importNode (node, deep); };
}

} // end if (guard)

if (typeof (LAPI.WP) == 'undefined') {

LAPI.WP = {

  getContentDiv : function (doc)
  {
    // Monobook, modern, classic skins
    return LAPI.$ (['bodyContent', 'mw_contentholder', 'article'], doc);
  },

  fullImageSizeFromPage : function (doc)
  {
    // Get the full img size. This is screenscraping :-( but there are times where you don't
    // want to get this info from the server using an Ajax call.
    // Note: we get the size from the file history table because the text just below the image
    // is all scrambled on RTL wikis. For instance, on ar-WP, it is
    // "‏ (1,806 × 1,341 بكسل، حجم الملف: 996 كيلوبايت، نوع الملف: image/jpeg) and with uselang=en, 
    // it is at ar-WP "‏ (1,806 × 1,341 pixels, file size: 996 KB, MIME type: image/jpeg)"
    // However, in the file history table, it looks good no matter the language and writing
    // direction.
    // Update: this fails on e.g. ar-WP because someone had the great idea to use localized
    // numerals, but the digit transform table is empty!
    var result = {width : 0, height : 0};
    var file_hist = LAPI.$ ('mw-imagepage-section-filehistory', doc);
    if (!file_hist) return result;
    try {
      var $file_curr = window.jQuery ? $(file_hist).find('td.filehistory-selected') : getElementsByClassName(file_hist, 'td', 'filehistory-selected');
      // Did they change the column order here? It once was nextSibling.nextSibling... but somehow
      // the thumbnails seem to be gone... Right:
      // http://svn.wikimedia.org/viewvc/mediawiki/trunk/phase3/includes/ImagePage.php?r1=52385&r2=53130
      file_hist = LAPI.DOM.getInnerText ($file_curr[0].nextSibling);
      if (!file_hist.contains ('×')) {
        file_hist = LAPI.DOM.getInnerText ($file_curr[0].nextSibling.nextSibling);
        if (!file_hist.contains ('×')) file_hist = null;
      }
    } catch (ex) {
      return result;
    }
    // Now we have "number×number" followed by something arbitrary 
    if (file_hist) {
      file_hist = file_hist.split ('×', 2);
      result.width  = parseInt (file_hist.shift ().replace (/[^0-9]/g, ""), 10);
      // Height is a bit more difficult because e.g. uselang=eo uses a space as the thousands
      // separator. Hence we have to extract this more carefully
      file_hist = file_hist.pop(); // Everything after the "×"
      // Remove any white space embedded between digits
      file_hist = file_hist.replace (/(\d)\s*(\d)/g, '$1$2');
      file_hist = file_hist.split (" ",2).shift ().replace (/[^0-9]/g, "");
      result.height = parseInt (file_hist, 10);
      if (isNaN (result.width) || isNaN (result.height)) result = {width : 0, height : 0};
    }
    return result;
  },

  getPreviewImage : function (title, doc)
  {
    var file_div = LAPI.$ ('file', doc);
    if (!file_div) return null; // Catch page without file...
    var imgs     = file_div.getElementsByTagName ('img');
    title = title || mw.config.get('wgTitle');
    for (var i = 0; i < imgs.length; i++) {
      var src = decodeURIComponent (imgs[i].getAttribute ('src', 2)).replace ('%26', '&');
      if (src.search (new RegExp ('^' + LAPI_file_store + '.*/' + title.replace (/ /g, '_').escapeRE () + '(/.*)?$')) == 0)
        return imgs[i];
    }
    return null;
  },

  pageFromLink : function (lk)
  {
    if (!lk) return null;
    var href = lk.getAttribute ('href', 2);
    if (!href) return null;
    // This is a bit tricky to get right, because wgScript can be a substring prefix of
    // wgArticlePath, or vice versa.
    var script = mw.config.get('wgScript') + '?';
    if (href.startsWith (script) || href.startsWith (mw.config.get('wgServer') + script) || mw.config.get('wgServer').startsWith('//') && href.startsWith (document.location.protocol + mw.config.get('wgServer') + script)) {
      // href="/w/index.php?title=..."
      return href.getParamValue ('title');
    }
    // Now try wgArticlePath: href="/wiki/..."
    var prefix = mw.config.get('wgArticlePath').replace ('$1', "");
    if (!href.startsWith (prefix)) prefix = mw.config.get('wgServer') + prefix; // Fully expanded URL?
    if (!href.startsWith (prefix) && prefix.startsWith ('//')) prefix = document.location.protocol + prefix; // Protocol-relative wgServer?
    if (href.startsWith (prefix))
      return decodeURIComponent (href.substring (prefix.length));
    // Do we have variants?
    var variants = mw.config.get('wgVariantArticlePath');
    if (variants && variants.length > 0)
    {
      var re =
        new RegExp (variants.escapeRE().replace ('\\$2', "[^\\/]*").replace ('\\$1', "(.*)"));
      var m  = re.exec (href);
      if (m && m.length > 1) return decodeURIComponent (m[m.length-1]);
    }
    // Finally alternative action paths
    var actions = mw.config.get('wgActionPaths');
    if (actions) {
      for (var i=0; i < actions.length; i++) {
        var p = actions[i];
        if (p && p.length > 0) {
          p = p.replace('$1', "");
          if (!href.startsWith (p)) p = mw.config.get('wgServer') + p;
          if (!href.startsWith (p) && p.startsWith('//')) p = document.location.protocol + p;
          if (href.startsWith (p))
            return decodeURIComponent (href.substring (p.length));
        }
      }
    }
    return null;
  },

  revisionFromHtml : function (htmlOfPage)
  {
    var revision_id = null;
    if (window.mediaWiki) { // MW 1.17+
      revision_id = htmlOfPage.match (/(mediaWiki|mw).config.set\(\{.*"wgCurRevisionId"\s*:\s*(\d+),/);
      if (revision_id) revision_id = parseInt (revision_id[2], 10);
    } else { // MW < 1.17
      revision_id = htmlOfPage.match (/wgCurRevisionId\s*=\s*(\d+)[;,]/);
      if (revision_id) revision_id = parseInt (revision_id[1], 10);
    }
    return revision_id;
  }

}; // end LAPI.WP

} // end if (guard)

if (typeof (LAPI.Ajax.doAction) == 'undefined') {

importScript ('MediaWiki:AjaxSubmit.js'); // Legacy code: ajaxSubmit

LAPI.Ajax.getXML = function (request, failureFunc)
{
  var doc = null;
  if (request.responseXML && request.responseXML.documentElement) {
    doc = request.responseXML;
  } else {
    try {
      doc = LAPI.DOM.parse (request.responseText, 'text/xml');
    } catch (ex) {
      if (typeof (failureFunc) == 'function') failureFunc (request, ex);
      doc = null;
    }
  }
  if (doc) {
    try {
      doc = LAPI.DOM.isValid (doc);
    } catch (ex) {
      if (typeof (failureFunc) == 'function') failureFunc (request, ex);
      doc = null;
    }
  }
  return doc;
};

LAPI.Ajax.getHTML = function (request, failureFunc, sanity_check)
{
  // Konqueror sometimes has severe problems with responseXML. It does set it, but getElementById
  // may fail to find elements known to exist.
  var doc = null;
  // Always use our own parser instead of responseXML; that doesn't work right with HTML5. (It did work with XHTML, though.)
//  if (   request.responseXML && request.responseXML.documentElement
//      && request.responseXML.documentElement.tagName == 'HTML'
//      && (!sanity_check || request.responseXML.getElementById (sanity_check) != null)
//     )
//  {
//    doc = request.responseXML;
//  } else {
    try {
      doc = LAPI.DOM.parseHTML (request.responseText, sanity_check);
      if (!doc) throw new Error ('#Could not understand request result');
    } catch (ex) {
      if (typeof (failureFunc) == 'function') failureFunc (request, ex);
      doc = null;
    }
//  }
  if (doc) {
    try {
      doc = LAPI.DOM.isValid (doc);
    } catch (ex) {
      if (typeof (failureFunc) == 'function') failureFunc (request, ex);
      doc = null;
    }
  }
  if (doc === null) return doc;
  // We've gotten XML. There is a subtle difference between XML and (X)HTML concerning leading newlines in textareas:
  // XML is required to pass through any whitespace (http://www.w3.org/TR/2004/REC-xml-20040204/#sec-white-space), whereas
  // HTML may or must not (e.g. http://www.w3.org/TR/html4/appendix/notes.html#h-B.3.1, though it is unclear whether that
  // really applies to the content of a textarea, but the draft HTML 5 spec explicitly says that the first newline in a
  // <textarea> is swallowed in HTML:
  // http://www.whatwg.org/specs/web-apps/current-work/multipage/syntax.html#element-restrictions).
  //   Because of the latter MW1.18+ adds a newline after the <textarea> start tag if the value starts with a newline. That
  // solves bug 12130 (leading newlines swallowed), but since XML passes us this extra newline, we might end up adding a
  // leading newline upon each edit.
  //   Let's try to make sure that all textarea's values are as they should be in HTML.
  // Note: since the above change to always use our own parser, which always returns a faked HTML document, this should be
  // unnecessary since doc.isFake should always be true.
  if (typeof (LAPI.Ajax.getHTML.extraNewlineRE) == 'undefined') {
    // Feature detection. Compare value after parsing with value after .innerHTML.
    LAPI.Ajax.getHTML.extraNewlineRE = null; // Don't know; hence do nothing
    try {
      var testTA = '<textarea id="test">\nTest</textarea>';
      var testString = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
                     + '<html xmlns="http://www.w3.org/1999/xhtml" lang="en" dir="ltr">\n'
                     + '<head><title>Test</title></head><body><form>' + testTA + '</form></body>\n'
                     + '</html>';
      var testDoc = LAPI.DOM.parseHTML (testString, 'test');
      var testVal = "" + testDoc.getElementById ('test').value;
      if (testDoc.dispose) testDoc.dispose();
      var testDiv = LAPI.make ('div', null, {display: 'none'});
      document.body.appendChild (testDiv);
      testDiv.innerHTML = testTA;
      if (testDiv.firstChild.value != testVal) {
        LAPI.Ajax.getHTML.extraNewlineRE = /^\r?\n/;
        if (testDiv.firstChild.value != testVal.replace(LAPI.Ajax.getHTML.extraNewlineRE, "")) {
          // Huh? Not the expected difference: go back to "don't know" mode
          LAPI.Ajax.getHTML.extraNewlineRE = null;
        }
      }
      LAPI.DOM.removeNode (testDiv);
    } catch (any) {
      LAPI.Ajax.getHTML.extraNewlineRE = null;
    }
  }
  if (!doc.isFake && LAPI.Ajax.getHTML.extraNewlineRE !== null) {
    // If have a "fake" doc, then we did parse through .innerHTML anyway. No need to fix anything.
    // (Hm. Maybe we should just always use a fake doc?)
    var tas = doc.getElementsByTagName ('textarea');
    for (var i = 0, l = tas.length; i < l; i++) {
      tas[i].value = tas[i].value.replace(LAPI.Ajax.getHTML.extraNewlineRE, "");
    }
  }
  return doc;
};

LAPI.Ajax.get = function (uri, params, success, failure, config)
{
  var original_failure = failure;
  if (!failure || typeof (failure) != 'function') failure = function () {};
  if (!success || typeof (success) != 'function')
    throw new Error ('No success function supplied for LAPI.Ajax.get '
                     + uri + ' with arguments ' + params.toString ());
  var request = LAPI.Ajax.getRequest ();
  if (!request) {
    failure (request);
    return;
  }
  var args = "";
  var question_mark = uri.indexOf ('?');
  if (question_mark) {
    args = uri.substring (question_mark + 1);
    uri  = uri.substring (0, question_mark);
  }
  if (params != null) {
    if (typeof (params) == 'string' && params.length > 0) {
      args += (args.length > 0 ? '&' : "")
            + ((params.charAt (0) == '&' || params.charAt (0) == '?')
                ? params.substring (1)
                : params
              ); // Must already be encoded!
    } else {
      for (var param in params) {
        args += (args.length > 0 ? '&' : "") + param;
        if (params[param] != null) args += '=' + encodeURIComponent (params[param]);
      }
    }
  }
  var method;
  if (uri.startsWith ('//')) uri = document.location.protocol + uri; // Avoid protocol-relative URIs (IE7 bug)
  if (uri.length + args.length + 1 < (LAPI.Browser.is_ie ? 2040 : 4080)) {
    // Both browsers and web servers may have limits on URL length. IE has a limit of 2083 characters
    // (2048 in the path part), and the WMF servers seem to impose a limit of 4kB.
    method = 'GET'; uri += '?' + args; args = null;
  } else {
    method = 'POST'; // We'll lose caching, but at least we can make the request.
  }
  request.open (method, uri, true);
  request.setRequestHeader ('Pragma', 'cache=yes');
  request.setRequestHeader (
      'Cache-Control'
    , 'no-transform'
      + (params && params.maxage ? ', max-age=' + params.maxage : "")
      + (params && params.smaxage ? ', s-maxage=' + params.smaxage : "")
  );
  if (config) {
    for (var conf in config) {
      if (conf == 'overrideMimeType') {
        if (config[conf] && config[conf].length > 0 && request.overrideMimeType)
          request.overrideMimeType (config[conf]);
      } else {
        request.setRequestHeader (conf, config[conf]);
      }      
    }
  }
  if (args) request.setRequestHeader ('Content-Type', 'application/x-www-form-urlencoded');
  request.onreadystatechange =
    function ()
    {
      if (request.readyState != 4) return; // Wait until the request has completed.
      try {
        if (request.status != 200)
          throw new Error
            ('#Request to server failed. Status: ' + request.status + ' ' + request.statusText
             + ' URI: ' + uri);
        if (!request.responseText)
          throw new Error ('#Empty response from server for request ' + uri);
      } catch (ex) {
        failure (request, ex);
        return;
      }
      success (request, original_failure);
    };
  request.send (args);
};

LAPI.Ajax.getPage = function (page, action, params, success, failure)
{
  var uri = mw.config.get('wgServer') + mw.config.get('wgScript') + '?title=' + encodeURIComponent (page)
        + (action ? '&action=' + action : "");
  LAPI.Ajax.get (uri, params, success, failure, {overrideMimeType : 'application/xml'});
};

// modify is supposed to save the changes at the end, e.g. using LAPI.Ajax.submit.
// modify is called with three parameters: the document, possibly the form, and the optional
// failure function. The failure function is called with the request as the first parameter,
// and possibly an exception as the second parameter.
LAPI.Ajax.doAction = function (page, action, form, modify, failure)
{
  if (!page || !action || !modify || typeof (modify) != 'function')
    throw new Error ('Parameter inconsistency in LAPI.Ajax.doAction.');
  var original_failure = failure;
  if (!failure || typeof (failure) != 'function') failure = function () {};
  LAPI.Ajax.getPage (
      page, action, null // No additional parameters
    , function (request, failureFunc) {
        var doc         = null;
        var the_form    = null;
        var revision_id = null;
        try {
          // Convert responseText into DOM tree.
          doc = LAPI.Ajax.getHTML (request, failureFunc, form);      
          if (!doc) return;
          var err_msg = LAPI.$ ('permissions-errors', doc);
          if (err_msg) throw new Error ('#' + LAPI.DOM.getInnerText (err_msg));
          if (form) {
            the_form = LAPI.$ (form, doc);
            if (!the_form) throw new Error ('#Server reply does not contain mandatory form.');
          }
          revision_id = LAPI.WP.revisionFromHtml (request.responseText);
        } catch (ex) {
          failureFunc (request, ex);
          return;
        }
        modify (doc, the_form, original_failure, revision_id)
      }
    , failure
  );
}; // end LAPI.Ajax.doAction
  
LAPI.Ajax.submit = function (form, after_submit)
{
  try {
    ajaxSubmit (form, null, after_submit, true); // Legacy code from MediaWiki:AjaxSubmit
  } catch (ex) {
    after_submit (null, ex);
  }
}; // end LAPI.Ajax.submit

LAPI.Ajax.editPage = function (page, modify, failure)
{
  LAPI.Ajax.doAction (page, 'edit', 'editform', modify, failure);
}; // end LAPI.Ajax.editPage
  
LAPI.Ajax.checkEdit = function (request)
{
  if (!request) return true;
  // Check for previews (session token lost?) or edit forms (edit conflict). 
  try {
    var doc = LAPI.Ajax.getHTML (request, function () {throw new Error ('Cannot check HTML');});
    if (!doc) return false;
    return LAPI.$ (['wikiPreview', 'editform'], doc) == null;
  } catch (anything) {
    return false;
  }
}; // end LAPI.Ajax.checkEdit
  
LAPI.Ajax.submitEdit = function (form, success, failure)
{
  if (!success || typeof (success) != 'function') success = function () {};
  if (!failure || typeof (failure) != 'function') failure = function () {};
  LAPI.Ajax.submit (
      form
    , function (request, ex)
      {
        if (ex) {
          failure (request, ex);
        } else {
          var successful = false;
          try {
            successful = request && request.status == 200 && LAPI.Ajax.checkEdit (request);
          } catch (some_error) {
            failure (request, some_error);
            return;
          }
          if (successful)
            success (request);
          else
            failure (request);
        }
      }
  );
}; // end LAPI.Ajax.submitEdit

LAPI.Ajax.apiGet = function (action, params, success, failure)
{
  var original_failure = failure;
  if (!failure || typeof (failure) != 'function') failure = function () {};
  if (!success || typeof (success) != 'function')
    throw new Error ('No success function supplied for LAPI.Ajax.apiGet '
                     + action + ' with arguments ' + params.toString ());
  var is_json = false;
  if (params != null) {
    if (typeof (params) == 'string') {
      if (!/format=[^&]+/.test (params)) params += '&format=json';
      is_json = /format=json(&|$)/.test (params); // Exclude jsonfm, which actually serves XHTML
    } else {
      if (typeof (params['format']) != 'string' || params.format.length == 0) params.format = 'json';
      is_json = params.format == 'json';
    }
  }
  var uri = mw.config.get('wgServer') + mw.config.get('wgScriptPath') + '/api.php' + (action ? '?action=' + action : "");
  LAPI.Ajax.get (
      uri, params
    , function (request, failureFunc) {
        if (is_json && request.responseText.trimLeft().charAt (0) != '{') {
          failureFunc (request);
        } else {
          success (
              request
            , (is_json ? eval ('(' + request.responseText.trimLeft() + ')') : null)
            , original_failure
          );
        }
      }
    , failure
  );
}; // end LAPI.Ajax.apiGet

LAPI.Ajax.parseWikitext = function (wikitext, success, failure, as_preview, user_language, on_page, cache)
{
  if (!failure || typeof (failure) != 'function') failure = function () {};
  if (!success || typeof (success) != 'function')
    throw new Error ('No success function supplied for parseWikitext');
  if (!wikitext && !on_page)
    throw new Error ('No wikitext or page supplied for parseWikitext');
  var params = null;
  if (!wikitext) {
    params = {pst: null, page: on_page};
  } else {
    params =
      { pst  : null // Do the pre-save-transform: Pipe magic, tilde expansion, etc.
       ,text :
          (as_preview ? '\<div style="border:1px solid red; padding:0.5em;"\>'
                        + '\<div class="previewnote"\>'
                        + '\{\{MediaWiki:Previewnote/' + (user_language || mw.config.get('wgUserLanguage')) +'\}\}'
                        + '\<\/div>\<div\>\n'
                      : "")
          + wikitext
          + (as_preview ? '\<\/div\>\<div style="clear:both;"\>\<\/div\>\<\/div\>' : "")
        ,title: on_page || mw.config.get('wgPageName') || "API"
      };
  }
  params.prop    = 'text';
  params.uselang = user_language || mw.config.get('wgUserLanguage'); // see bugzilla 22764
  if (cache && /^\d+$/.test(cache=cache.toString())) {
    params.maxage = cache;
    params.smaxage = cache;
  }
  LAPI.Ajax.apiGet (
      'parse'
    , params
    , function (req, json_result, failureFunc)
      {
        // Success.
        if (!json_result || !json_result.parse || !json_result.parse.text) {
          failureFunc (req, json_result);
          return;
        }
        success (json_result.parse.text['*'], failureFunc);
      }
    , failure
  );
}; // end LAPI.Ajax.parseWikitext

// Throbber backward-compatibility

LAPI.Ajax.injectSpinner = function (elementBefore, id) {}; // No-op, replaced as appropriate below.
LAPI.Ajax.removeSpinner = function (id) {}; // No-op, replaced as appropriate below.

if (typeof window.jQuery == 'undefined' || typeof window.mediaWiki == 'undefined' || typeof window.mediaWiki.loader == 'undefined') {
    // Assume old-stlye
	if (typeof window.injectSpinner != 'undefined') {
		LAPI.Ajax.injectSpinner = window.injectSpinner;
	}
	if (typeof window.removeSpinner != 'undefined') {
		LAPI.Ajax.removeSpinner = window.removeSpinner;
	}
} else {
	window.mediaWiki.loader.using('jquery.spinner', function () {
		LAPI.Ajax.injectSpinner = function (elementBefore, id) {
			window.jQuery(elementBefore).injectSpinner(id);
		}
		LAPI.Ajax.removeSpinner = function (id) {
			window.jQuery.removeSpinner(id);
		}
	});
}

} // end if (guard)

if (typeof (LAPI.Pos) == 'undefined') {
  
LAPI.Pos =
{
  // Returns the global coordinates of the mouse pointer within the document.
  mousePosition : function (evt)
  {
    if (!evt || (typeof (evt.pageX) == 'undefined' && typeof (evt.clientX) == 'undefined'))
      // No way to calculate a mouse pointer position
      return null;
    if (typeof (evt.pageX) != 'undefined')
      return { x : evt.pageX, y : evt.pageY };
      
    var offset      = LAPI.Pos.scrollOffset ();
    var mouse_delta = LAPI.Pos.mouse_offset ();
    var coor_x = evt.clientX + offset.x - mouse_delta.x;
    var coor_y = evt.clientY + offset.y - mouse_delta.y;
    return { x : coor_x, y : coor_y };
  },
  
  // Operations on document level:
  
  // Returns the scroll offset of the whole document (in other words, the coordinates
  // of the top left corner of the viewport).
  scrollOffset : function ()
  {
    return {x : LAPI.Pos.getScroll ('Left'), y : LAPI.Pos.getScroll ('Top') };
  },
  
  getScroll : function (what)
  {
    var s = 'scroll' + what;
    return (document.documentElement ? document.documentElement[s] : 0)
           || document.body[s] || 0;
  },
  
  // Returns the size of the viewport (result.x is the width, result.y the height).
  viewport : function ()
  {
    return {x : LAPI.Pos.getViewport ('Width'), y : LAPI.Pos.getViewport ('Height') };
  },
  
  getViewport : function (what)
  {
    if (   LAPI.Browser.is_opera_95 && what == 'Height'
        || LAPI.Browser.is_safari && !document.evaluate)
      return window['inner' + what];
    var s = 'client' + what;
    if (LAPI.Browser.is_opera) return document.body[s];
    return (document.documentElement ? document.documentElement[s] : 0)
           || document.body[s] || 0;
  },
  
  // Operations on DOM nodes
  
  position : (function ()
  {
    // The following is the jQuery.offset implementation. We cannot use jQuery yet in globally
    // activated scripts (it has strange side effects for Opera 8 users who can't log in anymore,
    // and it breaks the search box for some users). Note that jQuery does not support Opera 8.
    // Until the WMF servers serve jQuery by default, this copy from the jQuery 1.3.2 sources is
    // needed here. If and when we have jQuery available officially, the whole thing here can be
    // replaced by "var tmp = jQuery (node).offset(); return {x:tmp.left, y:tmp.top};"
    // Kudos to the jQuery development team. Any errors in this adaptation are my own. (Lupo,
    // 2009-08-24).

    var data = null;

    function jQuery_init ()
    {
      data = {};
      // Capability check from jQuery.
      var body = document.body;
      var container = document.createElement('div');
      var html =
          '<div style="position:absolute;top:0;left:0;margin:0;border:5px solid #000;'
        + 'padding:0;width:1px;height:1px;"><div></div></div><table style="position:absolute;'
        + 'top:0;left:0;margin:0;border:5px solid #000;padding:0;width:1px;height:1px;" '
        + 'cellpadding="0" cellspacing="0"><tr><td></td></tr></table>';
      var rules = { position: 'absolute', visibility: 'hidden'
                   ,top: 0, left: 0
                   ,margin: 0, border: 0
                   ,width: '1px', height: '1px'
                  };
      Object.merge (rules, container.style);

      container.innerHTML = html;
      body.insertBefore(container, body.firstChild);
      var innerDiv = container.firstChild;
      var checkDiv = innerDiv.firstChild;
      var td = innerDiv.nextSibling.firstChild.firstChild;

      data.doesNotAddBorder = (checkDiv.offsetTop !== 5);
      data.doesAddBorderForTableAndCells = (td.offsetTop === 5);

      innerDiv.style.overflow = 'hidden', innerDiv.style.position = 'relative';
      data.subtractsBorderForOverflowNotVisible = (checkDiv.offsetTop === -5);

      var bodyMarginTop    = body.style.marginTop;
      body.style.marginTop = '1px';
      data.doesNotIncludeMarginInBodyOffset = (body.offsetTop === 0);
      body.style.marginTop = bodyMarginTop;

      body.removeChild(container);
    };

    function jQuery_offset (node)
    {
      if (node === node.ownerDocument.body) return jQuery_bodyOffset (node);
      if (node.getBoundingClientRect) {
        var box    = node.getBoundingClientRect ();
        var scroll = LAPI.Pos.scrollOffset ();
        return {x : (box.left + scroll.x), y : (box.top + scroll.y)};
      }
      if (!data) jQuery_init ();
      var elem              = node;
      var offsetParent      = elem.offsetParent;
      var prevOffsetParent  = elem;
      var doc               = elem.ownerDocument;
      var prevComputedStyle = doc.defaultView.getComputedStyle(elem, null);
      var computedStyle;

      var top  = elem.offsetTop;
      var left = elem.offsetLeft;

      while ( (elem = elem.parentNode) && elem !== doc.body && elem !== doc.documentElement ) {
        computedStyle = doc.defaultView.getComputedStyle(elem, null);
        top -= elem.scrollTop, left -= elem.scrollLeft;
        if ( elem === offsetParent ) {
          top += elem.offsetTop, left += elem.offsetLeft;
          if (   data.doesNotAddBorder
              && !(data.doesAddBorderForTableAndCells && /^t(able|d|h)$/i.test(elem.tagName))
             )
          {
            top  += parseInt (computedStyle.borderTopWidth,  10) || 0;
            left += parseInt (computedStyle.borderLeftWidth, 10) || 0;
          }
          prevOffsetParent = offsetParent; offsetParent = elem.offsetParent;
        }
        if (data.subtractsBorderForOverflowNotVisible && computedStyle.overflow !== 'visible')
        {
          top  += parseInt (computedStyle.borderTopWidth,  10) || 0;
          left += parseInt (computedStyle.borderLeftWidth, 10) || 0;
        }
        prevComputedStyle = computedStyle;
      }

      if (prevComputedStyle.position === 'relative' || prevComputedStyle.position === 'static') {
        top  += doc.body.offsetTop;
        left += doc.body.offsetLeft;
      }
      if (prevComputedStyle.position === 'fixed') {
        top  += Math.max (doc.documentElement.scrollTop, doc.body.scrollTop);
        left += Math.max (doc.documentElement.scrollLeft, doc.body.scrollLeft);
      }
      return {x: left, y: top};            
    }

    function jQuery_bodyOffset (body)
    {
      if (!data) jQuery_init();
      var top = body.offsetTop, left = body.offsetLeft;
      if (data.doesNotIncludeMarginInBodyOffset) {
        top  += parseInt (LAPI.DOM.currentStyle (body, 'margin-top'), 10) || 0;
        left += parseInt (LAPI.DOM.currentStyle (body, 'margin-left'), 10) || 0;
      }
      return {x: left, y: top};
    }

    return jQuery_offset;
  })(),

  isWithin : function (node, x, y)
  {
    if (!node || !node.parentNode) return false;
    var pos = LAPI.Pos.position (node);
    return    (x == null || x > pos.x && x < pos.x + node.offsetWidth)
           && (y == null || y > pos.y && y < pos.y + node.offsetHeight);
  },
  
  // Private:
  
  // IE has some strange offset...
  mouse_offset : function ()
  {
    if (LAPI.Browser.is_ie) {
      var doc_elem = document.documentElement;
      if (doc_elem) {
        if (typeof (doc_elem.getBoundingClientRect) == 'function') {
          var tmp = doc_elem.getBoundingClientRect ();
          return {x : tmp.left, y : tmp.top};
        } else {
          return {x : doc_elem.clientLeft, y : doc_elem.clientTop};
        }
      }
    }
    return {x: 0, y : 0};
  }

}; // end LAPI.Pos

} // end if (guard)

if (typeof (LAPI.Evt) == 'undefined') {
  
LAPI.Evt =
{
  listenTo : function (object, node, evt, f, capture)
  {
    var listener = LAPI.Evt.makeListener (object, f);
    LAPI.Evt.attach (node, evt, listener, capture);
  },
 
  attach : function (node, evt, f, capture)
  {
    if (node.attachEvent) node.attachEvent ('on' + evt, f);
    else if (node.addEventListener) node.addEventListener (evt, f, capture);
    else node['on' + evt] = f;
  },
 
  remove : function (node, evt, f, capture)
  {
    if (node.detachEvent) node.detachEvent ('on' + evt, f);
    else if (node.removeEventListener) node.removeEventListener (evt, f, capture);
    else node['on' + evt] = null;
  },
 
  makeListener : function (obj, listener)
  {
    // Some hacking around to make sure 'this' is set correctly
    var object = obj, f = listener;
    return function (evt) { return f.apply (object, [evt || window.event]); }
    // Alternative implementation:
    // var f = listener.bind (obj);
    // return function (evt) { return f (evt || window.event); };
  },

  kill : function (evt)
  {
    if (typeof (evt.preventDefault) == 'function') {
      evt.stopPropagation ();
      evt.preventDefault (); // Don't follow the link
    } else if (typeof (evt.cancelBubble) != 'undefined') { // IE...
      evt.cancelBubble = true;
    }
    return false; // Don't follow the link (IE)
  }

}; // end LAPI.Evt

} // end if (guard)

if (typeof (LAPI.Edit) == 'undefined') {

LAPI.Edit = function () {this.initialize.apply (this, arguments);};

LAPI.Edit.SAVE    = 1;
LAPI.Edit.PREVIEW = 2;
LAPI.Edit.REVERT  = 4;
LAPI.Edit.CANCEL  = 8;

LAPI.Edit.prototype =
{
  initialize : function (initial_text, columns, rows, labels, handlers)
  {
    var my_labels =
      {box : null, preview : null, save : 'Save', cancel : 'Cancel', nullsave : null, revert : null, post: null};
    if (labels) my_labels = Object.merge (labels, my_labels);
    this.labels = my_labels;
    this.timestamp = (new Date ()).getTime ();
    this.id = 'simpleedit_' + this.timestamp;
    this.view = LAPI.make ('div', {id : this.id}, {marginRight: '1em'});
    // Somehow, the textbox extends beyond the bounding box of the view. Don't know why, but
    // adding a small margin fixes the layout more or less.
    this.form =
      LAPI.make (
          'form'
        , { id      : this.id + '_form'
           ,action  : ""
           ,onsubmit: (function () {})
          }
      );
    if (my_labels.box) {
      var label = LAPI.make ('div');
      label.appendChild (LAPI.DOM.makeLabel (this.id + '_label', my_labels.box, this.id + '_text'));
      this.form.appendChild (label);
    }
    this.textarea =
      LAPI.make (
          'textarea'
        , { id   : this.id + '_text'
           ,cols : columns
           ,rows : rows
           ,value: (initial_text ? initial_text.toString () : "")
          }
      );
    LAPI.Evt.attach (this.textarea, 'keyup', LAPI.Evt.makeListener (this, this.text_changed));
    // Catch cut/copy/paste through the context menu. Some browsers support oncut, oncopy,
    // onpaste events for this, but since that's only IE, FF 3, Safari 3, and Chrome, we
    // cannot rely on this. Instead, we check again as soon as we leave the textarea. Only
    // minor catch is that on FF 3, the next focus target is determined before the blur event
    // fires. Since in practice save will always be enabled, this shouldn't be a problem.
    LAPI.Evt.attach (this.textarea, 'mouseout', LAPI.Evt.makeListener (this, this.text_changed));
    LAPI.Evt.attach (this.textarea, 'blur', LAPI.Evt.makeListener (this, this.text_changed));
    this.form.appendChild (this.textarea);
    this.form.appendChild (LAPI.make ('br'));
    this.preview_section =
      LAPI.make ('div', null, {borderBottom: '1px solid #8888aa', display: 'none'});
    this.view.insertBefore (this.preview_section, this.view.firstChild);
    this.save =
      LAPI.DOM.makeButton
        (this.id + '_save', my_labels.save, LAPI.Evt.makeListener (this, this.do_save));
    this.form.appendChild (this.save);
    if (my_labels.preview) {
      this.preview =
        LAPI.DOM.makeButton
          (this.id + '_preview', my_labels.preview, LAPI.Evt.makeListener (this, this.do_preview));
      this.form.appendChild (this.preview);
    }
    this.cancel =
      LAPI.DOM.makeButton
        (this.id + '_cancel', my_labels.cancel, LAPI.Evt.makeListener (this, this.do_cancel));
    this.form.appendChild (this.cancel);
    this.view.appendChild (this.form);
    if (my_labels.post) {
      this.post_text = LAPI.DOM.setContent (LAPI.make ('div'), my_labels.post);
      this.view.appendChild (this.post_text);
    }
    if (handlers) Object.merge (handlers, this);
    if (typeof (this.ongettext) != 'function')
      this.ongettext = function (text) { return text;}; // Default: no modifications
    this.current_mask = LAPI.Edit.SAVE + LAPI.Edit.PREVIEW + LAPI.Edit.REVERT + LAPI.Edit.CANCEL;
    if ((!initial_text || initial_text.trim ().length == 0) && this.preview)
      this.preview.disabled = true;
    if (my_labels.revert) {
      this.revert = 
        LAPI.DOM.makeButton
          (this.id + '_revert', my_labels.revert, LAPI.Evt.makeListener (this, this.do_revert));
      this.form.insertBefore (this.revert, this.cancel);
    }
    this.original_text = "";
  },
  
  getView : function ()
  {
    return this.view;
  },
  
  getText : function ()
  {
    return this.ongettext (this.textarea.value);
  },
  
  setText : function (text)
  {
    this.textarea.value = text;
    this.original_text  = text;
    this.text_changed ();
  },
  
  changeText : function (text)
  {
    this.textarea.value = text;
    this.text_changed ();
  },

  hidePreview : function ()
  {
    this.preview_section.style.display = 'none';
    if (this.onpreview) this.onpreview (this);
  },

  showPreview : function ()
  {
    this.preview_section.style.display = "";
    if (this.onpreview) this.onpreview (this);
  },

  setPreview : function (html)
  {
    if (html.nodeName) {
      LAPI.DOM.removeChildren (this.preview_section);
      this.preview_section.appendChild (html);
    } else {
      this.preview_section.innerHTML = html;
    }
  },
  
  busy : function (show)
  {
    if (show)
      LAPI.Ajax.injectSpinner (this.cancel, this.id + '_spinner');
    else
      LAPI.Ajax.removeSpinner (this.id + '_spinner');
  },
  
  do_save : function (evt)
  {
    if (this.onsave) this.onsave (this);
    return true;
  },
  
  do_revert : function (evt)
  {
    this.changeText (this.original_text);
    return true;
  },

  do_cancel : function (evt)
  {
    if (this.oncancel) this.oncancel (this);
    return true;
  },
  
  do_preview : function (evt)
  {
    var self = this;
    this.busy (true);
    LAPI.Ajax.parseWikitext (
        this.getText ()
      , function (text, failureFunc)
        {
          self.busy (false);
          self.setPreview (text);
          self.showPreview ();
        }
      , function (req, json_result)
        {
          // Error. TODO: user feedback?
          self.busy (false);
        }
      , true
      , mw.config.get('wgUserLanguage') || null
      , mw.config.get('wgPageName') || null
    );
    return true;
  },

  enable : function (bit_set)
  {
    var call_text_changed = false;
    this.current_mask = bit_set;
    this.save.disabled = ((bit_set & LAPI.Edit.SAVE) == 0);
    this.cancel.disabled = ((bit_set & LAPI.Edit.CANCEL) == 0);
    if (this.preview) {
      if ((bit_set & LAPI.Edit.PREVIEW) == 0)
        this.preview.disabled = true;
      else
        call_text_changed = true;
    }
    if (this.revert) {
      if ((bit_set & LAPI.Edit.REVERT) == 0)
        this.revert.disabled = true;
      else
        call_text_changed = true;
    }
    if (call_text_changed) this.text_changed ();
  },

  text_changed : function (evt)
  {
    var text = this.textarea.value;
    text = text.trim ();
    var length = text.length;   
    if (this.preview && (this.current_mask & LAPI.Edit.PREVIEW) != 0) {
      // Preview is basically enabled
      this.preview.disabled = (length <= 0);
    }
    if (this.labels.nullsave) {
      if (length > 0) {
        this.save.value = this.labels.save;
      } else {
        this.save.value = this.labels.nullsave;
      }
    }
    if (this.revert) {
      this.revert.disabled =
        (text == this.original_text || this.textarea.value == this.original_text);
    }
    return true;
  }

}; // end LAPI.Edit

} // end if (guard)

// </source>