MediaWiki:LAPI.js

From electowiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// <syntaxhighlight 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)
*/
/* jshint unused:false, laxcomma:true, smarttabs:true, loopfunc:true, forin:false */
// 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?:)?//static\\.miraheze\\.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 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.
if (!String.prototype.startsWith) {
	String.prototype.startsWith = function (prefix) {
		return this.indexOf(prefix) === 0;
	};
}

// Returns true if the string ends in suffix
if (!String.prototype.endsWith) {
	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
			// "\u200f (1,806 × 1,341 بكسل، حجم الملف: 996 كيلوبايت، نوع الملف: image/jpeg) and with uselang=en, 
			// it is at ar-WP "\u200f (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 = imgs[i].getAttribute('src', 2);
				if (src && src.search(/^data/)) {
					src = decodeURIComponent(src).replace('%26', '&');
					if (!src.search(new RegExp('^' + LAPI_file_store + '.*/' + title.replace(/ /g, '_').escapeRE() + '(/.*)?$')))
						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)

// </syntaxhighlight>