跳转到内容

User:Jonathan5566/js/afcma.js/core.js

维基百科,自由的百科全书

注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。

/* https://github.com/94rain/afch-zhwp, translated and adapted from https://github.com/WPAFC/afch-rewrite */

var Hogan = {};

(function (Hogan, useArrayBuffer) {
	Hogan.Template = function (renderFunc, text, compiler, options) {
		this.r = renderFunc || this.r;
		this.c = compiler;
		this.options = options;
		this.text = text || '';
		this.buf = (useArrayBuffer) ? [] : '';
	}

	Hogan.Template.prototype = {
		// render: replaced by generated code.
		r: function (context, partials, indent) { return ''; },

		// variable escaping
		v: hoganEscape,

		// triple stache
		t: coerceToString,

		render: function render(context, partials, indent) {
			return this.ri([context], partials || {}, indent);
		},

		// render internal -- a hook for overrides that catches partials too
		ri: function (context, partials, indent) {
			return this.r(context, partials, indent);
		},

		// tries to find a partial in the curent scope and render it
		rp: function (name, context, partials, indent) {
			var partial = partials[name];

			if (!partial) {
				return '';
			}

			if (this.c && typeof partial == 'string') {
				partial = this.c.compile(partial, this.options);
			}

			return partial.ri(context, partials, indent);
		},

		// render a section
		rs: function (context, partials, section) {
			var tail = context[context.length - 1];

			if (!isArray(tail)) {
				section(context, partials, this);
				return;
			}

			for (var i = 0; i < tail.length; i++) {
				context.push(tail[i]);
				section(context, partials, this);
				context.pop();
			}
		},

		// maybe start a section
		s: function (val, ctx, partials, inverted, start, end, tags) {
			var pass;

			if (isArray(val) && val.length === 0) {
				return false;
			}

			if (typeof val == 'function') {
				val = this.ls(val, ctx, partials, inverted, start, end, tags);
			}

			pass = (val === '') || !!val;

			if (!inverted && pass && ctx) {
				ctx.push((typeof val == 'object') ? val : ctx[ctx.length - 1]);
			}

			return pass;
		},

		// find values with dotted names
		d: function (key, ctx, partials, returnFound) {
			var names = key.split('.'),
				val = this.f(names[0], ctx, partials, returnFound),
				cx = null;

			if (key === '.' && isArray(ctx[ctx.length - 2])) {
				return ctx[ctx.length - 1];
			}

			for (var i = 1; i < names.length; i++) {
				if (val && typeof val == 'object' && names[i] in val) {
					cx = val;
					val = val[names[i]];
				} else {
					val = '';
				}
			}

			if (returnFound && !val) {
				return false;
			}

			if (!returnFound && typeof val == 'function') {
				ctx.push(cx);
				val = this.lv(val, ctx, partials);
				ctx.pop();
			}

			return val;
		},

		// find values with normal names
		f: function (key, ctx, partials, returnFound) {
			var val = false,
				v = null,
				found = false;

			for (var i = ctx.length - 1; i >= 0; i--) {
				v = ctx[i];
				if (v && typeof v == 'object' && key in v) {
					val = v[key];
					found = true;
					break;
				}
			}

			if (!found) {
				return (returnFound) ? false : "";
			}

			if (!returnFound && typeof val == 'function') {
				val = this.lv(val, ctx, partials);
			}

			return val;
		},

		// higher order templates
		ho: function (val, cx, partials, text, tags) {
			var compiler = this.c;
			var options = this.options;
			options.delimiters = tags;
			var text = val.call(cx, text);
			text = (text == null) ? String(text) : text.toString();
			this.b(compiler.compile(text, options).render(cx, partials));
			return false;
		},

		// template result buffering
		b: (useArrayBuffer) ? function (s) { this.buf.push(s); } :
			function (s) { this.buf += s; },
		fl: (useArrayBuffer) ? function () { var r = this.buf.join(''); this.buf = []; return r; } :
			function () { var r = this.buf; this.buf = ''; return r; },

		// lambda replace section
		ls: function (val, ctx, partials, inverted, start, end, tags) {
			var cx = ctx[ctx.length - 1],
				t = null;

			if (!inverted && this.c && val.length > 0) {
				return this.ho(val, cx, partials, this.text.substring(start, end), tags);
			}

			t = val.call(cx);

			if (typeof t == 'function') {
				if (inverted) {
					return true;
				} else if (this.c) {
					return this.ho(t, cx, partials, this.text.substring(start, end), tags);
				}
			}

			return t;
		},

		// lambda replace variable
		lv: function (val, ctx, partials) {
			var cx = ctx[ctx.length - 1];
			var result = val.call(cx);

			if (typeof result == 'function') {
				result = coerceToString(result.call(cx));
				if (this.c && ~result.indexOf("{\u007B")) {
					return this.c.compile(result, this.options).render(cx, partials);
				}
			}

			return coerceToString(result);
		}

	};

	var rAmp = /&/g,
		rLt = /</g,
		rGt = />/g,
		rApos = /\'/g,
		rQuot = /\"/g,
		hChars = /[&<>\"\']/;


	function coerceToString(val) {
		return String((val === null || val === undefined) ? '' : val);
	}

	function hoganEscape(str) {
		str = coerceToString(str);
		return hChars.test(str) ?
			str
				.replace(rAmp, '&amp;')
				.replace(rLt, '&lt;')
				.replace(rGt, '&gt;')
				.replace(rApos, '&#39;')
				.replace(rQuot, '&quot;') :
			str;
	}

	var isArray = Array.isArray || function (a) {
		return Object.prototype.toString.call(a) === '[object Array]';
	};

})(typeof exports !== 'undefined' ? exports : Hogan);




(function (Hogan) {
	// Setup regex  assignments
	// remove whitespace according to Mustache spec
	var rIsWhitespace = /\S/,
		rQuot = /\"/g,
		rNewline = /\n/g,
		rCr = /\r/g,
		rSlash = /\\/g,
		tagTypes = {
			'#': 1, '^': 2, '/': 3, '!': 4, '>': 5,
			'<': 6, '=': 7, '_v': 8, '{': 9, '&': 10
		};

	Hogan.scan = function scan(text, delimiters) {
		var len = text.length,
			IN_TEXT = 0,
			IN_TAG_TYPE = 1,
			IN_TAG = 2,
			state = IN_TEXT,
			tagType = null,
			tag = null,
			buf = '',
			tokens = [],
			seenTag = false,
			i = 0,
			lineStart = 0,
			otag = '{{',
			ctag = '}}';

		function addBuf() {
			if (buf.length > 0) {
				tokens.push(new String(buf));
				buf = '';
			}
		}

		function lineIsWhitespace() {
			var isAllWhitespace = true;
			for (var j = lineStart; j < tokens.length; j++) {
				isAllWhitespace =
					(tokens[j].tag && tagTypes[tokens[j].tag] < tagTypes['_v']) ||
					(!tokens[j].tag && tokens[j].match(rIsWhitespace) === null);
				if (!isAllWhitespace) {
					return false;
				}
			}

			return isAllWhitespace;
		}

		function filterLine(haveSeenTag, noNewLine) {
			addBuf();

			if (haveSeenTag && lineIsWhitespace()) {
				for (var j = lineStart, next; j < tokens.length; j++) {
					if (!tokens[j].tag) {
						if ((next = tokens[j + 1]) && next.tag == '>') {
							// set indent to token value
							next.indent = tokens[j].toString()
						}
						tokens.splice(j, 1);
					}
				}
			} else if (!noNewLine) {
				tokens.push({ tag: '\n' });
			}

			seenTag = false;
			lineStart = tokens.length;
		}

		function changeDelimiters(text, index) {
			var close = '=' + ctag,
				closeIndex = text.indexOf(close, index),
				delimiters = trim(
					text.substring(text.indexOf('=', index) + 1, closeIndex)
				).split(' ');

			otag = delimiters[0];
			ctag = delimiters[1];

			return closeIndex + close.length - 1;
		}

		if (delimiters) {
			delimiters = delimiters.split(' ');
			otag = delimiters[0];
			ctag = delimiters[1];
		}

		for (i = 0; i < len; i++) {
			if (state == IN_TEXT) {
				if (tagChange(otag, text, i)) {
					--i;
					addBuf();
					state = IN_TAG_TYPE;
				} else {
					if (text.charAt(i) == '\n') {
						filterLine(seenTag);
					} else {
						buf += text.charAt(i);
					}
				}
			} else if (state == IN_TAG_TYPE) {
				i += otag.length - 1;
				tag = tagTypes[text.charAt(i + 1)];
				tagType = tag ? text.charAt(i + 1) : '_v';
				if (tagType == '=') {
					i = changeDelimiters(text, i);
					state = IN_TEXT;
				} else {
					if (tag) {
						i++;
					}
					state = IN_TAG;
				}
				seenTag = i;
			} else {
				if (tagChange(ctag, text, i)) {
					tokens.push({
						tag: tagType, n: trim(buf), otag: otag, ctag: ctag,
						i: (tagType == '/') ? seenTag - ctag.length : i + otag.length
					});
					buf = '';
					i += ctag.length - 1;
					state = IN_TEXT;
					if (tagType == '{') {
						if (ctag == '}}') {
							i++;
						} else {
							cleanTripleStache(tokens[tokens.length - 1]);
						}
					}
				} else {
					buf += text.charAt(i);
				}
			}
		}

		filterLine(seenTag, true);

		return tokens;
	}

	function cleanTripleStache(token) {
		if (token.n.substr(token.n.length - 1) === '}') {
			token.n = token.n.substring(0, token.n.length - 1);
		}
	}

	function trim(s) {
		if (s.trim) {
			return s.trim();
		}

		return s.replace(/^\s*|\s*$/g, '');
	}

	function tagChange(tag, text, index) {
		if (text.charAt(index) != tag.charAt(0)) {
			return false;
		}

		for (var i = 1, l = tag.length; i < l; i++) {
			if (text.charAt(index + i) != tag.charAt(i)) {
				return false;
			}
		}

		return true;
	}

	function buildTree(tokens, kind, stack, customTags) {
		var instructions = [],
			opener = null,
			token = null;

		while (tokens.length > 0) {
			token = tokens.shift();
			if (token.tag == '#' || token.tag == '^' || isOpener(token, customTags)) {
				stack.push(token);
				token.nodes = buildTree(tokens, token.tag, stack, customTags);
				instructions.push(token);
			} else if (token.tag == '/') {
				if (stack.length === 0) {
					throw new Error('Closing tag without opener: /' + token.n);
				}
				opener = stack.pop();
				if (token.n != opener.n && !isCloser(token.n, opener.n, customTags)) {
					throw new Error('Nesting error: ' + opener.n + ' vs. ' + token.n);
				}
				opener.end = token.i;
				return instructions;
			} else {
				instructions.push(token);
			}
		}

		if (stack.length > 0) {
			throw new Error('missing closing tag: ' + stack.pop().n);
		}

		return instructions;
	}

	function isOpener(token, tags) {
		for (var i = 0, l = tags.length; i < l; i++) {
			if (tags[i].o == token.n) {
				token.tag = '#';
				return true;
			}
		}
	}

	function isCloser(close, open, tags) {
		for (var i = 0, l = tags.length; i < l; i++) {
			if (tags[i].c == close && tags[i].o == open) {
				return true;
			}
		}
	}

	Hogan.generate = function (tree, text, options) {
		var code = 'var _=this;_.b(i=i||"");' + walk(tree) + 'return _.fl();';
		if (options.asString) {
			return 'function(c,p,i){' + code + ';}';
		}

		return new Hogan.Template(new Function('c', 'p', 'i', code), text, Hogan, options);
	}

	function esc(s) {
		return s.replace(rSlash, '\\\\')
			.replace(rQuot, '\\\"')
			.replace(rNewline, '\\n')
			.replace(rCr, '\\r');
	}

	function chooseMethod(s) {
		return (~s.indexOf('.')) ? 'd' : 'f';
	}

	function walk(tree) {
		var code = '';
		for (var i = 0, l = tree.length; i < l; i++) {
			var tag = tree[i].tag;
			if (tag == '#') {
				code += section(tree[i].nodes, tree[i].n, chooseMethod(tree[i].n),
					tree[i].i, tree[i].end, tree[i].otag + " " + tree[i].ctag);
			} else if (tag == '^') {
				code += invertedSection(tree[i].nodes, tree[i].n,
					chooseMethod(tree[i].n));
			} else if (tag == '<' || tag == '>') {
				code += partial(tree[i]);
			} else if (tag == '{' || tag == '&') {
				code += tripleStache(tree[i].n, chooseMethod(tree[i].n));
			} else if (tag == '\n') {
				code += text('"\\n"' + (tree.length - 1 == i ? '' : ' + i'));
			} else if (tag == '_v') {
				code += variable(tree[i].n, chooseMethod(tree[i].n));
			} else if (tag === undefined) {
				code += text('"' + esc(tree[i]) + '"');
			}
		}
		return code;
	}

	function section(nodes, id, method, start, end, tags) {
		return 'if(_.s(_.' + method + '("' + esc(id) + '",c,p,1),' +
			'c,p,0,' + start + ',' + end + ',"' + tags + '")){' +
			'_.rs(c,p,' +
			'function(c,p,_){' +
			walk(nodes) +
			'});c.pop();}';
	}

	function invertedSection(nodes, id, method) {
		return 'if(!_.s(_.' + method + '("' + esc(id) + '",c,p,1),c,p,1,0,0,"")){' +
			walk(nodes) +
			'};';
	}

	function partial(tok) {
		return '_.b(_.rp("' + esc(tok.n) + '",c,p,"' + (tok.indent || '') + '"));';
	}

	function tripleStache(id, method) {
		return '_.b(_.t(_.' + method + '("' + esc(id) + '",c,p,0)));';
	}

	function variable(id, method) {
		return '_.b(_.v(_.' + method + '("' + esc(id) + '",c,p,0)));';
	}

	function text(id) {
		return '_.b(' + id + ');';
	}

	Hogan.parse = function (tokens, text, options) {
		options = options || {};
		return buildTree(tokens, '', [], options.sectionTags || []);
	},

		Hogan.cache = {};

	Hogan.compile = function (text, options) {
		// options
		//
		// asString: false (default)
		//
		// sectionTags: [{o: '_foo', c: 'foo'}]
		// An array of object with o and c fields that indicate names for custom
		// section tags. The example above allows parsing of {{_foo}}{{/foo}}.
		//
		// delimiters: A string that overrides the default delimiters.
		// Example: "<% %>"
		//
		options = options || {};

		var key = text + '||' + !!options.asString;

		var t = this.cache[key];

		if (t) {
			return t;
		}

		t = this.generate(this.parse(this.scan(text, options.delimiters), text, options), text, options);
		return this.cache[key] = t;
	};
})(typeof exports !== 'undefined' ? exports : Hogan);

;//<nowiki>
(function (AFCH, $, mw) {
	$.extend(AFCH, {

		/**
		 * Log anything to the console
		 * @param {anything} thing(s)
		 */
		log: function () {
			var args = Array.prototype.slice.call(arguments);

			if (AFCH.consts.beta && console && console.log) {
				args.unshift('AFCH:');
				console.log.apply(console, args);
			}
		},

		/**
		 * @internal Functions called when AFCH.destroy() is run
		 * @type {Array}
		 */
		_destroyFunctions: [],

		/**
		 * Add a function to run when AFCH.destroy() is run
		 * @param {Function} fn
		 */
		addDestroyFunction: function (fn) {
			AFCH._destroyFunctions.push(fn);
		},

		/**
		 * Destroys all AFCH-y things. Subscripts can add custom
		 * destroy functions by running AFCH.addDestroyFunction( fn )
		 */
		destroy: function () {
			$.each(AFCH._destroyFunctions, function (_, fn) {
				fn();
			});

			window.AFCH = false;
		},

		/**
		 * Prepares the AFCH gadget by setting constants and checking environment
		 * @return {bool} Whether or not all setup functions executed successfully
		 */
		setup: function () {
			// Check requirements
			if ('ajax' in $.support && !$.support.ajax) {
				AFCH.error = 'AFCH requires AJAX';
				return false;
			}
			AFCH.consts.beta = true;

			AFCH.api = new mw.Api();

			// Set up the preferences interface
			AFCH.preferences = new AFCH.Preferences();
			AFCH.prefs = AFCH.preferences.prefStore;

			// Add more constants -- don't overwrite those already set, though
			AFCH.consts = $.extend({}, {
				// If true, the script will NOT modify actual wiki content and
				// will instead mock all such API requests (success assumed)
				mockItUp: false,
				// Full page name, "Wikipedia talk:Articles for creation/sandbox"
				pagename: mw.config.get('wgPageName').replace(/_/g, ' '),
				// Link to the current page, "/wiki/Wikipedia talk:建立條目專題/沙盒"
				pagelink: mw.util.getUrl(),
				// Used when status is disabled
				nullstatus: { update: function () { return; } },
				// Current user
				user: mw.user.getName(),
				// Edit summary ad
				summaryAd: ' ([[WP:AFCH|AFCH]])',
				// Require users to be on whitelist to use the script
				whitelistRequired: true,
				// Name of the whitelist page for reviewers
				whitelistTitle: 'WikiProject:建立條目/參與者'
			}, AFCH.consts);

			// Check whitelist if necessary, but don't delay loading of the
			// script for users who ARE allowed; rather, just destroy the
			// script instance when and if it finds the user is not listed
			if (AFCH.consts.whitelistRequired) {
				AFCH.checkWhitelist();
			}

			return true;
		},

		/**
		 * Check if the current user is allowed to use the helper script;
		 * if not, display an error and destroy AFCH
		 */
		checkWhitelist: function () {
			var user = AFCH.consts.user,
				whitelist = new AFCH.Page(AFCH.consts.whitelistTitle);
			whitelist.getText().done(function (text) {

				// sanitizedUser is user, but escaped for use in the regex.
				// Otherwise a user named ... would always be able to use
				// the script, so long as there was a user whose name was
				// three characters long on the list!
				var $howToDisable,
					sanitizedUser = user.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'),
					userAllowed = (new RegExp('\\|\\s*' + sanitizedUser + '\\s*}')).test(text);

				if (!userAllowed) {

					// If we can detect that the gadget is currently enabled, offer a one-click "disable" link
					if (mw.user.options.get('gadget-afchelper') === '1') {
						$howToDisable = $('<span>')
							.append(wgULS('如果要禁用辅助脚本,', '如果要禁用輔助腳本,'))
							.append($('<a>')
								.text(wgULS('点击这里', '點擊這裡'))
								.click(function () {
									// Submit the API request to disable the gadget.
									// Note: We don't use `AFCH.api` here, because AFCH has already been
									// destroyed due to the user not being on the whitelist!
									(new mw.Api()).postWithToken('options', {
										action: 'options',
										change: 'gadget-afchelper=0'
									}).done(function (data) {
										mw.notify('AFCH已被成功禁用。');
									});
								})
							)
							.append('. ');

						// Otherwise, AFCH is probably installed via common.js/skin.js -- offer links for easy access.
					} else {
						$howToDisable = $('<span>')
							.append(wgULS('如果要禁用幫助程序腳本,則需要手動', '如果要禁用幫助程序腳本,則需要手動') +
								wgULS('從您的', '從您的'))
							.append(AFCH.makeLinkElementToPage('Special:MyPage/common.js', 'common.js'))
							.append('或')
							.append(AFCH.makeLinkElementToPage('Special:MyPage/skin.js', 'skin.js'))
							.append(wgULS('頁面中移除。', '頁面中移除。'));
					}

					// Finally, make and push the notification, then explode AFCH
					mw.notify(
						$('<div>')
							.append(wgULS('AFCH不能加載,"', 'AFCH不能加載,"') + user + wgULS('"沒有列在', '"沒有列在'))
							.append(AFCH.makeLinkElementToPage(whitelist.rawTitle))
							.append(wgULS('。您可以在那裡申請使用AFC輔助腳本的權限。', '。您可以在那裡申請使用AFC輔助腳本的權限。'))
							.append($howToDisable)
							.append(wgULS('如果您有任何問題或疑慮,請', '如果您有任何問題或疑慮,請'))
							.append(AFCH.makeLinkElementToPage('WT:AFCH', wgULS('尋求幫助', '尋求幫助')))
							.append('!'),
						{
							title: wgULS('AFCH錯誤:用戶不在允許列表中', 'AFCH錯誤:用戶不在允許列表中'),
							autoHide: false
						}
					);
					AFCH.destroy();
				}
			});
		},

		/**
		 * Loads the subscript and dependencies
		 * @param {string} type Which type of script to load:
		 *                      'redirects' or 'ffu' or 'submissions'
		 */
		load: function (type) {
			if (!AFCH.setup()) {
				return false;
			}

			if (AFCH.consts.beta) {
				// Load minified css
				mw.loader.load(AFCH.consts.scriptpath + '?action=raw&ctype=text/css&title=User:94rain/js/afch-master.css', 'text/css');
				// Load dependencies
				mw.loader.load([
					// jquery resources
					'jquery.chosen',
					'jquery.spinner',
					'jquery.ui',

					// mediawiki.api
					'mediawiki.api',
					'mediawiki.api.titleblacklist',

					// mediawiki plugins
					'mediawiki.feedback'
				]);
			}

			// And finally load the subscript
			$.getScript(AFCH.consts.baseurl + '/' + type + '.js');

			return true;
		},

		/**
		 * Appends a feedback link to the given element
		 * @param {string|jQuery} $element The jQuery element or selector to which the link should be appended
		 * @param {string} type (optional) The part of AFCH that feedback is being given for, e.g. "files for upload"
		 * @param {string} linkText (optional) Text to display in the link; by default "Give feedback!"
		 */
		initFeedback: function ($element, type, linkText) {
			var feedback = new mw.Feedback({
				title: new mw.Title('Wikipedia talk:建立條目專題/協助腳本'),
				bugsLink: 'https://zh.wikipedia.org/w/index.php?title=WikiProject_talk:建立條目/協助腳本&action=edit&section=new',
				bugsListLink: 'https://zh.wikipedia.org/w/index.php?title=WikiProject_talk:建立條目/協助腳本'
			});
			$('<span>')
				.text(linkText || wgULS('提供反饋!', '提供反饋!'))
				.addClass('feedback-link link')
				.click(function () {
					feedback.launch({
						subject: '[' + AFCH.consts.version + '] ' + (type ? 'Feedback about ' + type : 'AFCH feedback')
					});
				})
				.appendTo($element);
		},

		/**
		 * Represents a page, mainly a wrapper for various actions
		 */
		Page: function (name) {
			var pg = this;

			this.title = new mw.Title(name);
			this.rawTitle = this.title.getPrefixedText();

			this.additionalData = {};
			this.hasAdditionalData = false;

			this.toString = function () {
				return this.rawTitle;
			};

			this.edit = function (options) {
				var deferred = $.Deferred();

				AFCH.actions.editPage(this.rawTitle, options)
					.done(function (data) {
						deferred.resolve(data);
					});

				return deferred;
			};

			/**
			 * Makes an API request to get a variety of details about the current
			 * revision of the page, which it then sets.
			 * @param {bool} usecache if true, will resolve immediately if function has
			 *                        run successfully before
			 * @return {$.Deferred} resolves when data set successfully
			 */
			this._revisionApiRequest = function (usecache) {
				var deferred = $.Deferred();

				if (usecache && pg.hasAdditionalData) {
					return deferred.resolve();
				}

				AFCH.actions.getPageText(this.rawTitle, {
					hide: true,
					moreProps: 'timestamp|user|ids',
					moreParameters: { rvgeneratexml: true }
				}).done(function (pagetext, data) {
					// Set internal data
					pg.pageText = pagetext;
					pg.additionalData.lastModified = new Date(data.timestamp);
					pg.additionalData.lastEditor = data.user;
					pg.additionalData.rawTemplateModel = data.parsetree;
					pg.additionalData.revId = data.revid;

					pg.hasAdditionalData = true;

					// Resolve; it's now safe to request this data
					deferred.resolve();
				});

				return deferred;
			};

			/**
			 * Gets the page text
			 * @param {bool} usecache use cache if possible
			 * @return {string}
			 */
			this.getText = function (usecache) {
				var deferred = $.Deferred();

				this._revisionApiRequest(usecache).done(function () {
					deferred.resolve(pg.pageText);
				});

				return deferred;
			};

			/**
			 * Gets templates on the page
			 * @return {array} array of objects, each representing a template like
			 *                       {
			 *                           target: 'templateName',
			 *                           params: { 1: 'foo', test: 'go to the {{bar}}' }
			 *                       }
			 */
			this.getTemplates = function () {
				var $templateDom, templates = [],
					deferred = $.Deferred();

				this._revisionApiRequest(true).done(function () {
					$templateDom = $($.parseXML(pg.additionalData.rawTemplateModel)).find('root');

					// We only want top level templates
					$templateDom.children('template').each(function () {
						var $el = $(this),
							data = {
								target: $el.children('title').text(),
								params: {}
							};

						/**
						 * Essentially, this function takes a template value DOM object, $v,
						 * and removes all signs of XML-ishness. It does this by manipulating
						 * the raw text and doing a few choice string replacements to change
						 * the templates to use wikicode syntax instead. Rather than messing
						 * with recursion and all that mess, /g is our friend...which is pefectly
						 * satisfactory for our purposes.
						 */
						function parseValue($v) {
							var text = AFCH.jQueryToHtml($v);

							// Convert templates to look more template-y
							text = text.replace(/<template>/g, '{{');
							text = text.replace(/<\/template>/g, '}}');
							text = text.replace(/<part>/g, '|');

							// Expand embedded tags (like <nowiki>)
							text = text.replace(new RegExp('<ext><name>(.*?)<\\/name>(?:<attr>.*?<\\/attr>)*' +
								'<inner>(.*?)<\\/inner><close>(.*?)<\\/close><\\/ext>', 'g'), '&lt;$1&gt;$2$3');

							// Now convert it back to text, removing all the rest of the XML tags
							return $(text).text();
						}

						$el.children('part').each(function () {
							var $part = $(this),
								$name = $part.children('name'),
								// Use the name if set, or fall back to index if implicitly numbered
								name = $.trim($name.text() || $name.attr('index')),
								value = $.trim(parseValue($part.children('value')));

							data.params[name] = value;
						});

						templates.push(data);
					});

					deferred.resolve(templates);
				});

				return deferred;
			};

			/**
			 * Gets the categories from the page
			 * @param {bool} useApi If true, use the api to get categories, instead of parsing the page. This is
			 *                      necessary if you need info about transcluded categories.
			 * @param {bool} includeCategoryLinks If true, will also include links to categories (e.g. [[:Category:Foo]]).
			 *                                    Note that if useApi is true, includeCategoryLinks must be false.
			 * @return {array}
			 */
			this.getCategories = function (useApi, includeCategoryLinks) {
				var deferred = $.Deferred(),
					text = this.pageText;

				if (useApi) {
					AFCH.api.getCategories(this.title).done(function (categories) {
						// The api returns mw.Title objects, so we convert them to simple
						// strings before resolving the deferred.
						deferred.resolve(categories ? $.map(categories, function (cat) {
							return cat.getPrefixedText();
						}) : []);
					});
					return deferred;
				}

				this._revisionApiRequest(true).done(function () {
					var catRegex = new RegExp('\\[\\[' + (includeCategoryLinks ? ':?' : '') + 'Category:(.*?)\\s*\\]\\]', 'gi'),
						match = catRegex.exec(text),
						categories = [];

					while (match) {
						// Name of each category, with first letter capitalized
						categories.push(match[1].charAt(0).toUpperCase() + match[1].substring(1));
						match = catRegex.exec(text);
					}

					deferred.resolve(categories);
				});

				return deferred;
			};

			this.getLastModifiedDate = function () {
				var deferred = $.Deferred();

				this._revisionApiRequest(true).done(function () {
					deferred.resolve(pg.additionalData.lastModified);
				});

				return deferred;
			};

			this.getLastEditor = function () {
				var deferred = $.Deferred();

				this._revisionApiRequest(true).done(function () {
					deferred.resolve(pg.additionalData.lastEditor);
				});

				return deferred;
			};

			this.getCreator = function () {
				var request, deferred = $.Deferred();

				if (this.additionalData.creator) {
					deferred.resolve(this.additionalData.creator);
					return deferred;
				}

				request = {
					action: 'query',
					prop: 'revisions',
					rvprop: 'user',
					rvdir: 'newer',
					rvlimit: 1,
					indexpageids: true,
					titles: this.rawTitle,
					tool: 'AFCH'
				};

				// FIXME: Handle failure more gracefully
				AFCH.api.get(request)
					.done(function (data) {
						var rev, id = data.query.pageids[0];
						if (id && data.query.pages[id]) {
							rev = data.query.pages[id].revisions[0];
							pg.additionalData.creator = rev.user;
							deferred.resolve(rev.user);
						} else {
							deferred.reject(data);
						}
					});

				return deferred;
			};

			this.exists = function () {
				var deferred = $.Deferred();

				AFCH.api.get({
					action: 'query',
					prop: 'info',
					titles: this.rawTitle
				}).done(function (data) {
					// A nonexistent page will be indexed as '-1'
					if (data.query.pages.hasOwnProperty('-1')) {
						deferred.resolve(false);
					} else {
						deferred.resolve(true);
					}
				});

				return deferred;
			};

			/**
			 * Gets the associated talk page
			 * @return {AFCH.Page}
			 */
			this.getTalkPage = function (textOnly) {
				var title, ns = this.title.getNamespaceId();

				// Odd-numbered namespaces are already talk namespaces
				if (ns % 2 !== 0) {
					return this;
				}

				title = new mw.Title(this.title.getMainText(), ns + 1);

				return new AFCH.Page(title.getPrefixedText());
			};

		},

		/**
		 * Perform a specific action
		 */
		actions: {
			/**
			 * Gets the full wikicode content of a page
			 * @param {string} pagename The page to get the contents of, namespace included
			 * @param {object} options Object with properties:
			 *                          hide: {bool} set to true to hide the API request in the status log
			 *                          moreProps: {string} additional properties to request, separated by `|`,
			 *                          moreParameters: {object} additioanl query parameters
			 * @return {$.Deferred} Resolves with pagetext and full data available as parameters
			 */
			getPageText: function (pagename, options) {
				var status, request, rvprop = 'content',
					deferred = $.Deferred();

				if (!options.hide) {
					status = new AFCH.status.Element('获取$1...',
						{ $1: AFCH.makeLinkElementToPage(pagename) });
				} else {
					status = AFCH.consts.nullstatus;
				}

				if (options.moreProps) {
					rvprop += '|' + options.moreProps;
				}

				request = {
					action: 'query',
					prop: 'revisions',
					rvprop: rvprop,
					format: 'json',
					indexpageids: true,
					titles: pagename,
					tool: 'AFCH'
				};

				$.extend(request, options.moreParameters || {});

				AFCH.api.get(request)
					.done(function (data) {
						var rev, id = data.query.pageids[0];
						if (id && data.query.pages) {
							// The page might not exist; resolve with an empty string
							if (id === '-1') {
								deferred.resolve('', {});
								return;
							}

							rev = data.query.pages[id].revisions[0];
							deferred.resolve(rev['*'], rev);
							status.update('已获取$1');
						} else {
							deferred.reject(data);
							// FIXME: get detailed error info from API result
							status.update('获取$1失败: ' + JSON.stringify(data));
						}
					})
					.fail(function (err) {
						deferred.reject(err);
						status.update('无法获取$1: ' + JSON.stringify(err));
					});

				return deferred;
			},

			/**
			 * Modifies a page's content
			 * @param {string} pagename The page to be modified, namespace included
			 * @param {object} options Object with properties:
			 *                          contents: {string} the text to add to/replace the page,
			 *                          summary: {string} edit summary, will have the edit summary ad at the end,
			 *                          createonly: {bool} set to true to only edit the page if it doesn't exist,
			 *                          mode: {string} 'appendtext' or 'prependtext'; default: (replace everything)
			 *                          hide: {bool} Set to true to supress logging in statusWindow
			 *                          statusText: {string} message to show in status; default: "Editing"
			 * @return {jQuery.Deferred} Resolves if saved with all data
			 */
			editPage: function (pagename, options) {
				var status, request, deferred = $.Deferred();

				if (!options) {
					options = {};
				}

				if (!options.hide) {
					status = new AFCH.status.Element((options.statusText || '正在编辑') + '$1...',
						{ $1: AFCH.makeLinkElementToPage(pagename) });
				} else {
					status = AFCH.consts.nullstatus;
				}

				request = {
					action: 'edit',
					text: options.contents,
					title: pagename,
					summary: options.summary + AFCH.consts.summaryAd
				};

				// Depending on mode, set appendtext=text or prependtext=text,
				// which overrides the default text option
				if (options.mode) {
					request[options.mode] = options.contents;
				}

				if (AFCH.consts.mockItUp) {
					AFCH.log(request);
					deferred.resolve();
					return deferred;
				}

				AFCH.api.postWithToken('edit', request)
					.done(function (data) {
						var $diffLink;

						if (data && data.edit && data.edit.result && data.edit.result === 'Success') {
							deferred.resolve(data);

							if (data.edit.hasOwnProperty('nochange')) {
								status.update(wgULS('沒有對$1作出任何更改', '沒有對$1作出任何更改'));
								return;
							}

							// Create a link to the diff of the edit
							$diffLink = AFCH.makeLinkElementToPage(
								'Special:Diff/' + data.edit.oldrevid + '/' + data.edit.newrevid, wgULS('(差異)', '(差異)')
							).addClass('text-smaller');

							status.update('已保存$1的更改' + AFCH.jQueryToHtml($diffLink));
						} else {
							deferred.reject(data);
							// FIXME: get detailed error info from API result??
							status.update(wgULS('保存$1的更改失敗:', '保存$1的更改失敗:') + JSON.stringify(data));
						}
					})
					.fail(function (err) {
						deferred.reject(err);
						status.update(wgULS('保存$1的更改失敗:', '保存$1的更改失敗:') + JSON.stringify(err));
					});

				return deferred;
			},

			/**
			 * Deletes a page
			 * @param  {string} pagename Page to delete
			 * @param  {string} reason   Reason for deletion; shown in deletion log
			 * @return {$.Deferred} Resolves with success/failure
			 */
			deletePage: function (pagename, reason) {
				// FIXME: implement
				return false;
			},

			/**
			 * Moves a page
			 * @param {string} oldTitle Page to move
			 * @param {string} newTitle Move target
			 * @param {string} reason Reason for moving; shown in move log
			 * @param {object} additionalParameters https://www.mediawiki.org/wiki/API:Move#Parameters
			 * @param {bool} hide Don't show the move in the status display
			 * @return {$.Deferred} Resolves with success/failure
			 */
			movePage: function (oldTitle, newTitle, reason, additionalParameters, hide) {
				var status, request, deferred = $.Deferred();

				if (!hide) {
					status = new AFCH.status.Element(wgULS('正在移動$1至$2...', '正在移動$1至$2...'), {
						$1: AFCH.makeLinkElementToPage(oldTitle),
						$2: AFCH.makeLinkElementToPage(newTitle)
					});
				} else {
					status = AFCH.consts.nullstatus;
				}

				request = $.extend({
					action: 'move',
					from: oldTitle,
					to: newTitle,
					reason: reason + AFCH.consts.summaryAd
				}, additionalParameters);

				if (AFCH.consts.mockItUp) {
					AFCH.log(request);
					deferred.resolve({ to: newTitle });
					return deferred;
				}

				AFCH.api.postWithToken('edit', request) // Move token === edit token
					.done(function (data) {
						if (data && data.move) {
							status.update(wgULS('移動$1至$2', '移動$1至$2'));
							deferred.resolve(data.move);
						} else {
							// FIXME: get detailed error info from API result??
							status.update(wgULS('移動$1至$2失敗:', '移動$1至$2失敗:') + JSON.stringify(data.error));
							deferred.reject(data.error);
						}
					})
					.fail(function (err) {
						status.update(wgULS('移動$1至$2失敗:', '移動$1至$2失敗:') + JSON.stringify(err));
						deferred.reject(err);
					});

				return deferred;
			},

			/**
			 * Notifies a user. Follows redirects and appends a message
			 * to the bottom of the user's talk page.
			 * @param  {string} user
			 * @param  {object} data object with properties
			 *                   - message: {string}
			 *                   - summary: {string}
			 *                   - hide: {bool}, default false
			 * @return {$.Deferred} Resolves with success/failure
			 */
			notifyUser: function (user, options) {
				var deferred = $.Deferred(),
					userTalkPage = new AFCH.Page(new mw.Title(user, 3).getPrefixedText()); // 3 = user talk namespace
				talkPageName = 'User talk:' + user;
				AFCH.api.get({
					action: 'query',
					prop: 'info',
					titles: talkPageName
				}).done(function (data) {
					var pages = data.query.pages;
					var pageId = Object.keys(pages)[0];
					var cm = pages[pageId].contentmodel;
					if (cm == 'flow-board') {
						AFCH.api.postWithToken('csrf', {
							action: 'flow',
							page: talkPageName,
							submodule: 'new-topic',
							nttopic: options.summary,
							ntcontent: options.message,
							ntformat: 'wikitext'
						})
						var status = new AFCH.status.Element((wgULS('嘗試對結構化討論頁面', '嘗試對結構化討論頁面')) + '$1' + wgULS('做出了编辑,请检查此次', '做出了編輯,請檢查此次') + '$2 $3',
							{ $1: AFCH.makeLinkElementToPage(talkPageName), $2: AFCH.makeLinkElementToPage(talkPageName, wgULS('编辑', ' 編輯')), $3: AFCH.makeLinkElementToPage('WT:AFCH', wgULS('(错误报告)', '(錯誤報告)')) });
					} else if (cm == 'wikitext') {
						userTalkPage.exists().done(function (exists) {
							userTalkPage.edit({
								contents: (exists ? '' : '{{Talk header}}') + '\n\n' + options.message,
								summary: options.summary || '通知用户',
								mode: 'appendtext',
								statusText: '通知',
								hide: options.hide
							})
						})
					} else {
						deferred.rejected();
					}
					deferred.resolved();
				}).fail(function (data) {
					deferred.rejected();
				});
				return deferred;
			},

			/**
			 * Logs a CSD nomination
			 * @param {object} options
			 *                  - title {string}
			 *                  - reason {string}
			 *                  - usersNotified {array} optional
			 * @return {$.Deferred} resolves false if the page did not exist, otherwise
			 *                      resolves/rejects with data from the edit
			 */
			logCSD: function (options) {
				var deferred = $.Deferred(),
					logPage = new AFCH.Page('User:' + mw.config.get('wgUserName') + '/' +
						(window.Twinkle && window.Twinkle.getPref('speedyLogPageName') || 'CSD日志'));

				// Abort if user disabled in preferences
				if (!AFCH.prefs.logCsd) {
					return;
				}

				logPage.getText().done(function (logText) {
					var status,
						date = new Date(),
						headerRe = new RegExp('^==+\\s*' + date.getUTCMonthName() + '\\s+' + date.getUTCFullYear() + '\\s*==+', 'm'),
						appendText = '';

					// Don't edit if the page has doesn't exist or has no text
					if (!logText) {
						deferred.resolve(false);
						return;
					}

					// Add header for new month if necessary
					if (!headerRe.test(logText)) {
						appendText += '\n\n=== ' + date.getUTCMonthName() + ' ' + date.getUTCFullYear() + ' ===';
					}

					appendText += '\n# [[:' + options.title + ']]: ' + options.reason;

					if (options.usersNotified && options.usersNotified.length) {
						appendText += '; 通知{{user|1=' + options.usersNotified.shift() + '}}';

						$.each(options.usersNotified, function (_, user) {
							appendText += ', {{user|1=' + user + '}}';
						});
					}

					appendText += ' ~~' + '~~' + '~\n';

					logPage.edit({
						contents: appendText,
						mode: 'appendtext',
						summary: wgULS('記錄對[[', '記錄對[[') + options.title + ']]的快速删除提名',
						statusText: wgULS('記錄快速刪除提名', '記錄快速刪除提名')
					}).done(function (data) {
						deferred.resolve(data);
					}).fail(function (data) {
						deferred.reject(data);
					});
				});

				return deferred;
			},

			/**
			 * If user is allowed, marks a given recentchanges ID as patrolled
			 * @param {string|number} rcid rcid to mark as patrolled
			 * @param {string} title Prettier title to display. If not specified, falls back to just
			 *                       displaying the rcid instead.
			 * @return {$.Deferred}
			 */
			patrolRcid: function (rcid, title) {
				var request, deferred = $.Deferred(),
					status = new AFCH.status.Element(wgULS('正在將$1標記為已巡查...', '正在將$1標記為已巡查...'),
						{ $1: AFCH.makeLinkElementToPage(title) || 'page with id #' + rcid });

				request = {
					action: 'patrol',
					rcid: rcid
				};

				if (AFCH.consts.mockItUp) {
					AFCH.log(request);
					deferred.resolve();
					return deferred;
				}

				AFCH.api.postWithToken('patrol', request).done(function (data) {
					if (data.patrol && data.patrol.rcid) {
						status.update('已巡查$1');
						deferred.resolve(data);
					} else {
						status.update(wgULS('將$1標記為已巡查失敗:', '將$1標記為已巡查失敗:') + JSON.stringify(data.patrol));
						deferred.reject(data);
					}
				}).fail(function (data) {
					status.update(wgULS('將$1標記為已巡查失敗:', '將$1標記為已巡查失敗:') + JSON.stringify(data));
					deferred.reject(data);
				});

				return deferred;
			}
		},

		/**
		 * Series of functions for logging statuses and whatnot
		 */
		status: {

			/**
			 * Represents the status container, created ub init()
			 */
			container: false,

			/**
			 * Creates the status container
			 * @param  {selector} location String/jQuery selector for where the
			 *                             status container should be prepended
			 */
			init: function (location) {
				AFCH.status.container = $('<div>')
					.attr('id', 'afchStatus')
					.addClass('afchStatus')
					.prependTo(location || '#mw-content-text');
			},

			/**
			 * Represents an element in the status container
			 * @param  {string} initialText Initial text of the element
			 * @param {object} substitutions key-value pairs of strings that should be replaced by something
			 *                               else. For example, { '$2': mw.user.getUser() }. If not redefined, $1
			 *                               will be equal to the current page name.
			 */
			Element: function (initialText, substitutions) {
				/**
				 * Replace the status element with new html content
				 * @param  {jQuery|string} html Content of the element
				 *                              Can use $1 to represent the page name
				 */
				this.update = function (html) {
					// Convert to HTML first if necessary
					if (html.jquery) {
						html = AFCH.jQueryToHtml(html);
					}

					// First run the substutions
					$.each(this.substitutions, function (key, value) {
						// If we are passed a jQuery object, convert it to regular HTML first
						if (value.jquery) {
							value = AFCH.jQueryToHtml(value);
						}

						html = html.replace(key, value);
					});
					// Then update the element
					this.element.html(html);
				};

				/**
				 * Remove the element from the status container
				 */
				this.remove = function () {
					this.update('');
				};

				// Sanity check, there better be a status container
				if (!AFCH.status.container) {
					AFCH.status.init();
				}

				if (!substitutions) {
					substitutions = { $1: AFCH.consts.pagelink };
				} else {
					substitutions = $.extend({}, { $1: AFCH.consts.pagelink }, substitutions);
				}

				this.substitutions = substitutions;

				this.element = $('<li>')
					.appendTo(AFCH.status.container);

				this.update(initialText);
			}
		},


		msg: {
			/**
			 * AFCH messages loaded by default for all subscripts.
			 * @type {Object}
			 */
			store: {},

			/**
			 * Retrieve the text of a message, or a placeholder if the
			 * message is not set
			 * @param {string} key Message key
			 * @param {object} substitutions replacements to make
			 * @return {string} Message value
			 */
			get: function (key, substitutions) {
				var text = AFCH.msg.store[key] || '<' + key + '>';

				// Perform substitutions if necessary
				if (substitutions) {
					$.each(substitutions, function (original, replacement) {
						text = text.replace(
							// Escape the original substitution key, then make it a global regex
							new RegExp(original.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'),
							replacement
						);
					});
				}

				return text;
			},

			/**
			 * Set a new message or messages
			 * @param {string|object} key
			 * @param {string} value if key is a string, value
			 */
			set: function (key, value) {
				if (typeof key === 'object') {
					$.extend(AFCH.msg.store, key);
				} else {
					AFCH.msg.store[key] = value;
				}
			}
		},

		/**
		 * Store persistent data for the user. Data is stored over
		 * several layers: window-locally, in a variable; broswer-locally,
		 * via localStorage, and finally not-so-locally-at-all, via
		 * mw.user.options.
		 *
		 * == REDUNDANCY, EXPLAINED ==
		 * The reason for this redundancy is because of an obnoxious
		 * little thing called caching. Ideally the script would simply
		 * use mw.user.options, but *apparently* MediaWiki doesn't always
		 * provide the most updated mw.user.options on page load -- in some
		 * instances, it will provide an stale, cached version instead.
		 * This is most certainly a MediaWiki bug, but in the meantime, we
		 * circumvent it by adding numerous layers of redundancy to the whole
		 * getup. In this manner, hopefully by the time we have to rely on
		 * mw.user.options, the cache will have been invalidated and the world
		 * won't explode. *sighs repeatedly* --Theopolisme, 26 May 2014
		 *
		 * @type {Object}
		 */
		userData: {
			/** @internal */
			_prefix: 'userjs-afch-',

			/**
			 * @internal
			 * This is used to cache the updated values of recently set
			 * (through AFCH.userData.set) options, since mw.user.options.get
			 * won't include items set after the page was first loaded.
			 * @type {Object}
			 */
			_optsCache: {},

			/**
			 * Set a value in the data store
			 * @param {string} key
			 * @param {mixed} value
			 * @return {$.Deferred} success
			 */
			set: function (key, value) {
				var deferred = $.Deferred(),
					fullKey = AFCH.userData._prefix + key,
					fullValue = JSON.stringify(value);

				// Update cache so AFCH.userData.get() will have updated
				// information if the page isn't reloaded first. If for
				// some reason the post fails...oh well...
				AFCH.userData._optsCache[fullKey] = fullValue;

				// Also update localStorage cache for more redundancy.
				// See note in AFCH.userData docs for why this is necessary.
				if (window.localStorage) {
					window.localStorage[fullKey] = fullValue;
				}

				AFCH.api.postWithToken('options', {
					action: 'options',
					optionname: fullKey,
					optionvalue: fullValue
				}).done(function (data) {
					deferred.resolve(data);
				});

				return deferred;
			},

			/**
			 * Gets a value from the data store
			 * @param {string} key
			 * @param {mixed} fallback fallback if option not present
			 * @return {mixed} value
			 */
			get: function (key, fallback) {
				var value,
					fullKey = AFCH.userData._prefix + key,
					cachedWindow = AFCH.userData._optsCache[fullKey],
					cachedLocal = window.localStorage && window.localStorage[fullKey];

				// Use cached value if possible, see explanation in AFCH.userData docs.
				value = cachedWindow || cachedLocal;

				if (value) {
					return JSON.parse(value);
				}

				// Otherwise just use mw.user.options (with fallback).
				return JSON.parse(mw.user.options.get(fullKey, JSON.stringify(fallback || false)));
			}
		},

		/**
		 * AFCH.Preferences is a mechanism for accessing and altering user
		 * preferences in regards to the script.
		 *
		 * Preferences are edited by the user via a jquery.ui dialog and are
		 * saved and persist for the user using AFCH.userData.
		 *
		 * Typical usage:
		 *  AFCH.preferences = new AFCH.Preferences();
		 *  AFCH.preferences.initLink( $( '.put-prefs-link-here' ) );
		 *
		 * @type {object}
		 */
		Preferences: function () {
			var prefs = this;

			/**
			 * Default values for user preferences; details for each preference can be
			 * found inline in `templates/tpl-preferences.html`.
			 * @type {object}
			 */
			this.prefDefaults = {
				autoOpen: false,
				logCsd: true,
				launchLinkPosition: 'p-cactions'
			};

			/**
			 * Current user's preferences
			 * @type {object}
			 */
			this.prefStore = $.extend({}, this.prefDefaults, AFCH.userData.get('preferences', {}));

			/**
			 * Initializes the preferences modification dialog
			 */
			this.initDialog = function () {
				var $spinner = $.createSpinner({
					size: 'large',
					type: 'block'
				}).css('padding', '20px');

				if (!this.$dialog) {
					// Initialize the $dialog div
					this.$dialog = $('<div>');
				}

				// Until we finish lazy-loading the prefs interface,
				// show a spinner in its place.
				this.$dialog.empty().append($spinner);

				this.$dialog.dialog({
					width: 500,
					autoOpen: false,
					title: wgULS('AFCH参数设置', 'AFCH偏好設定'),
					modal: true,
					buttons: [
						{
							text: '取消',
							click: function () {
								prefs.$dialog.dialog('close');
							}
						},
						{
							text: wgULS('保存设置', '保存設置'),
							click: function () {
								prefs.save();
								prefs.$dialog.empty().append($spinner);
							}
						}
					]
				});

				// If we've already fetched the template, render immediately
				if (this.views) {
					this.renderMain();
				} else {
					// Otherwise, load the template file and *then* render
					$.ajax({
						type: 'GET',
						url: AFCH.consts.baseurl + '/tpl-preferences.js',
						dataType: 'text'
					}).done(function (data) {
						prefs.views = new AFCH.Views(data);
						prefs.renderMain();
					});
				}
			};

			/**
			 * Renders the main preferences menu in the $dialog
			 */
			this.renderMain = function () {
				if (!(this.views && this.$dialog)) {
					return;
				}

				// Empty the dialog and render the preferences view. Provides the values of all
				// of the preferences as variables, as well as an additional few used in other locations.
				this.$dialog.empty().append(
					this.views.renderView('preferences', $.extend({}, this.prefStore, {
						version: AFCH.consts.version,
						versionName: AFCH.consts.versionName,
						userAgent: window.navigator.userAgent
					}))
				);

				// Manually handle selecting the desired value in <select> menus
				this.$dialog.find('select').each(function () {
					var $select = $(this),
						id = $select.attr('id'),
						value = prefs.prefStore[id];
					$select.find('option[value="' + value + '"]').prop('selected', true);
				});
			};

			/**
			 * Updates prefs based on data in the dialog which
			 * is created in AFCH.preferences.init().
			 */
			this.save = function () {
				// First, hide the buttons so the user won't start multiple actions
				this.$dialog.dialog({ buttons: [] });

				// Now update the prefStore
				$.extend(this.prefStore, AFCH.getFormValues(this.$dialog.find('.afch-input')));

				// Set the new userData value
				AFCH.userData.set('preferences', this.prefStore).done(function () {
					// When we're done, close the dialog and notify the user
					prefs.$dialog.dialog('close');
					mw.notify(wgULS('AFCH: 偏好設定項保存成功!它們將在當前頁面重新加載或瀏覽其他頁面時生效。', 'AFCH: 偏好設定項保存成功!它們將在當前頁面重新加載或瀏覽其他頁面時生效。'));
				});
			};

			/**
			 * Adds a link to launch the preferences modification dialog
			 *
			 * @param {jQuery} $element element to append the link to
			 * @param {string} linkText text to display in the link
			 */
			this.initLink = function ($element, linkText) {
				$('<span>')
					.text(linkText || wgULS('更新設置', '更新設置'))
					.addClass('preferences-link link')
					.appendTo($element)
					.click(function () {
						prefs.initDialog();
						prefs.$dialog.dialog('open');
					});
			};
		},

		/**
		 * Represents a series of "views", aka templateable thingamajigs.
		 * When creating a set of views, they are loaded from a given piece of
		 * text. Uses <hogan.js>.
		 *
		 * Views on the cheap! Just use one mega template and divide it up into
		 * lots of baby templates :)
		 *
		 * @param {string} [src] text to parse for template contents initially
		 */
		Views: function (src) {
			this.views = {};

			this.setView = function (name, content) {
				this.views[name] = content;
			};

			this.renderView = function (name, data) {
				var view = this.views[name],
					template = Hogan.compile(view);

				return template.render(data);
			};

			this.loadFromSrc = function (src) {
				var viewRegex = /<!--\s(.*?)\s-->\n([\s\S]*?)<!--\s\/(.*?)\s-->/g,
					match = viewRegex.exec(src);

				while (match !== null) {
					var key = match[1],
						content = match[2];

					this.setView(key, content);

					// Increment the match
					match = viewRegex.exec(src);
				}
			};

			this.loadFromSrc(src);
		},

		/**
		 * Represents a specific window into an AFCH.Views object
		 *
		 * @param {AFCH.Views} views location where the views are gleaned
		 * @param {jQuery} $element
		 */
		Viewer: function (views, $element) {
			this.views = views;
			this.$element = $element;

			this.previousState = false;

			this.loadView = function (view, data) {
				var code = this.views.renderView(view, data);

				// Update the view cache
				this.previousState = this.$element.clone(true);

				this.$element.html(code);
			};

			this.loadPrevious = function () {
				this.$element.replaceWith(this.previousState);
				this.$element = this.previousState;
			};
		},

		/**
		 * Removes a key from a given object and returns the value of the key
		 * @param {string} key
		 * @return {mixed}
		 */
		getAndDelete: function (object, key) {
			var v = object[key];
			delete object[key];
			return v;
		},

		/**
		 * Removes all occurences of a value from an array
		 * @param {array} array
		 * @param {mixed} value
		 */
		removeFromArray: function (array, value) {
			var index = $.inArray(value, array);
			while (index !== -1) {
				array.splice(index, 1);
				index = $.inArray(value, array);
			}
		},

		/**
		 * Gets the values of all elements matched by a selector, including
		 * converting checkboxes to bools, providing textual values of select
		 * elements, ignoring placeholder elements, and more.
		 *
		 * For a radio button group, pass in the container element, which must
		 * be a fieldset with the appropriate "name" attribute. Its id will
		 * be used as the key in the data object.
		 *
		 * @param {jQuery} $selector elements to get values from
		 * @return {object} object of values, with the ids as keys
		 */
		getFormValues: function ($selector) {
			var data = {};

			$selector.each(function (_, element) {
				var value, allTexts,
					$element = $(element);

				if (element.type === 'checkbox') {
					value = element.checked;
				} else if (element.type === 'fieldset') {
					value = $element.find(':checked').val();
				} else {
					value = $element.val();

					// Ignore placeholder text
					if (value === $element.attr('placeholder')) {
						value = '';
					}

					// For <select multiple> with nothing selected, jQuery returns null...
					// convert that to an empty array so that $.each() won't explode later
					if (value === null) {
						value = [];
					}

					// Also provide the full text of the selected options in <select>.
					// Primary use for this is the edit summary in handleDecline().
					if (element.nodeName.toLowerCase() === 'select') {
						allTexts = [];

						$element.find('option:selected').each(function () {
							allTexts.push($(this).text());
						});

						data[element.id + 'Texts'] = allTexts;
					}
				}

				data[element.id] = value;
			});

			return data;
		},

		/**
		 * Creates an <a> element that links to a given page.
		 * @param {string} pagename - The title of the page.
		 * @param {string} displayTitle - What gets shown by the link.
		 * @param {boolean} [newTab=true] - Whether to open page in a new tab.
		 * @return {jQuery} <a> element
		 */
		makeLinkElementToPage: function (pagename, displayTitle, newTab) {
			var actualTitle = pagename.replace(/_/g, ' ');

			// newTab is an optional parameter.
			newTab = (typeof newTab === 'undefined') ? true : newTab;

			return $('<a>')
				.attr('href', mw.util.getUrl(actualTitle))
				.attr('id', 'afch-cat-link-' + pagename.toLowerCase().replace(/ /g, '-').replace(/\//g, '-'))
				.attr('title', actualTitle)
				.text(displayTitle || actualTitle)
				.attr('target', newTab ? '_blank' : '_self');
		},

		/**
		 * Creates an <a> element that links to a random page in the given category.
		 * @param {string} pagename - The name of the category (without the namespace).
		 * @param {string} displayTitle - What gets shown by the link.
		 * @return {jQuery} <a> element
		 */
		makeLinkElementToCategory: function (pagename, displayTitle) {
			var linkElement = AFCH.makeLinkElementToPage('Special:RandomInCategory/' + pagename, displayTitle, false),
				linkText = displayTitle || pagename.replace(/_/g, ' '),
				request = {
					action: 'query',
					titles: 'Category:' + pagename,
					prop: 'categoryinfo'
				},
				linkSpan = $('<span>').append(linkElement),
				countSpanId = 'afch-cat-count-' + pagename
					.toLowerCase()
					.replace(/ /g, '-')
					.replace(/\//g, '-');

			linkSpan.append($('<span>').attr('id', countSpanId));

			AFCH.api.get(request)
				.done(function (data) {
					if (data.query.pages && !data.query.pages['-1']) {
						var pageKey = Object.keys(data.query.pages)[0],
							pagesCount = data.query.pages[pageKey].categoryinfo.pages;
						$('#' + countSpanId).text(' (' + pagesCount + ')');

						// Disable link if there aren't any pages
						$('#afch-cat-link-' + pagename.toLowerCase().replace(/ /g, '-').replace(/\//g, '-')).replaceWith(displayTitle);
					}
				});

			return linkSpan;
		},

		/**
		 * Converts [[wikilink]] -> <a>
		 *
		 * @param {string} wikicode
		 * @return {string}
		 */
		convertWikilinksToHTML: function (wikicode) {
			var newCode = wikicode,
				wikilinkRegex = /\[\[(.*?)\s*(?:\|\s*(.*?))?\]\]/g,
				wikilinkMatch = wikilinkRegex.exec(wikicode);

			while (wikilinkMatch) {
				var title = wikilinkMatch[1],
					displayTitle = wikilinkMatch[2],
					newLink = AFCH.makeLinkElementToPage(title, displayTitle);

				// Replace the wikilink with the new <a> element
				newCode = newCode.replace(wikilinkMatch[0], AFCH.jQueryToHtml(newLink));

				// Increment match
				wikilinkMatch = wikilinkRegex.exec(wikicode);
			}

			return newCode;
		},

		/**
		 * Returns the relative time that has elapsed between an oldDate and a nowDate
		 * @param {Date|string} old (if it is a string it will be assumed to be a
		 *                           MediaWiki timestamp and converted to a Date first)
		 * @param {Date} now optional, defaults to `new Date()`
		 * @return {string}
		 */
		relativeTimeSince: function (old, now) {
			var oldDate = typeof old === 'object' ? old : AFCH.mwTimestampToDate(old),
				nowDate = typeof now === 'object' ? now : new Date(),
				msPerMinute = 60 * 1000,
				msPerHour = msPerMinute * 60,
				msPerDay = msPerHour * 24,
				msPerMonth = msPerDay * 30,
				msPerYear = msPerDay * 365,
				elapsed = nowDate - oldDate,
				amount, unit;

			if (elapsed < msPerMinute) {
				amount = Math.round(elapsed / 1000);
				unit = '秒';
			} else if (elapsed < msPerHour) {
				amount = Math.round(elapsed / msPerMinute);
				unit = wgULS('分钟', '分钟');
			} else if (elapsed < msPerDay) {
				amount = Math.round(elapsed / msPerHour);
				unit = wgULS('小时', '小時');
			} else if (elapsed < msPerMonth) {
				amount = Math.round(elapsed / msPerDay);
				unit = '天';
			} else if (elapsed < msPerYear) {
				amount = Math.round(elapsed / msPerMonth);
				unit = '月';
			} else {
				amount = Math.round(elapsed / msPerYear);
				unit = '年';
			}

			if (amount !== 1) {
				unit += ' ';
			}

			return [amount, unit, '之前'].join(' ');
		},

		/**
		 * Converts an element into a toggle for another element
		 * @param {string} toggleSelector When clicked, will show/hide elementSelector
		 * @param {string} elementSelector Element(s) to be shown or hidden
		 * @param {string} showText e.g. "Show the div"
		 * @param {string} hideText e.g. "Hide the div"
		 */
		makeToggle: function (toggleSelector, elementSelector, showText, hideText) {
			// Remove current click handlers
			$(toggleSelector).off('click');

			// If show is true, we make the element visible and display hideText in
			// the toggle. Otherwise, we hide the element and display showText.
			function toggleState(show) {
				$(elementSelector).toggleClass('hidden', !show);
				$(toggleSelector).text(show ? hideText : showText);
			}

			// Update everythign to match current state of the element
			toggleState($(elementSelector).is(':visible'));

			// Add the new click handler
			$(document).on('click', toggleSelector, function () {
				toggleState($(elementSelector).hasClass('hidden'));
			});
		},

		/**
		 * Gets the full raw HTML content of a jQuery object
		 * @param {jQuery} $element
		 * @return {string}
		 */
		jQueryToHtml: function ($element) {
			return $('<div>').append($element).html();
		},

		/**
		 * Given a string, returns by default a Date() object
		 * or, if mwstyle is true, a MediaWiki-style timestamp
		 *
		 * If there is no match, return false
		 *
		 * @param {string} string string to parse
		 * @return {Date|integer}
		 */
		parseForTimestamp: function (string, mwstyle) {
			var exp, match, date;

			exp = new RegExp('(\\d{1,2}):(\\d{2}), (\\d{1,2}) ' +
				'(1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月) ' +
				'(\\d{4}) \\(UTC\\)', 'g');

			match = exp.exec(string);

			if (!match) {
				return false;
			}

			date = new Date();
			date.setUTCFullYear(match[5]);
			date.setUTCMonth(mw.config.get('wgMonthNames').indexOf(match[4]) - 1); // stupid javascript
			date.setUTCDate(match[3]);
			date.setUTCHours(match[1]);
			date.setUTCMinutes(match[2]);
			date.setUTCSeconds(0);

			if (mwstyle) {
				return AFCH.dateToMwTimestamp(date);
			}

			return date;
		},

		/**
		 * Parses a MediaWiki internal YYYYMMDDHHMMSS timestamp
		 * @param {string} string
		 * @return {Date|bool} if unable to parse, returns false
		 */
		mwTimestampToDate: function (string) {
			var date, dateMatches = /(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(string);

			// If it *isn't* actually a MediaWiki-style timestamp, pass directly to date
			if (dateMatches === null) {
				date = new Date(string);
				// Otherwise use Date.UTC to assemble a date object using UTC time
			} else {
				date = new Date(Date.UTC(
					dateMatches[1], dateMatches[2] - 1, dateMatches[3], dateMatches[4], dateMatches[5], dateMatches[6]
				));
			}

			// If invalid, return false
			if (isNaN(date.getUTCMilliseconds())) {
				return false;
			}

			return date;
		},

		/**
		 * Converts a Date object to YYYYMMDDHHMMSS format
		 * @param {Date} date
		 * @return {number}
		 */
		dateToMwTimestamp: function (date) {
			return +(date.getUTCFullYear() +
				('0' + (date.getUTCMonth() + 1)).slice(-2) +
				('0' + date.getUTCDate()).slice(-2) +
				('0' + date.getUTCHours()).slice(-2) +
				('0' + date.getUTCMinutes()).slice(-2) +
				('0' + date.getUTCSeconds()).slice(-2));
		},

		/**
		 * Returns the value of the specified URL parameter. By default it uses
		 * the current window's address. Optionally you can pass it a custom location.
		 * It returns null if the parameter is not present, or an empty string if the
		 * parameter is empty.
		 *
		 * @param {string} name parameter to get
		 * @param {string} url optional; custom url to search
		 * @return {string|null} value, or null if not present
		 */
		getParam: function () {
			return mw.util.getParamValue.apply(this, arguments);
		},

		/**
		 * Given a code for an AfC decline reason (e.g. "v"), returns some HTML code
		 * describing the reason.
		 *
		 * @param {string} code an AfC decline reason code
		 * @return {$.Deferred} Resolves with the requested HTML
		 */
		getReason: function (code) {
			var deferred = $.Deferred();

			$.post('https://zh.wikipedia.org/api/rest_v1/transform/wikitext/to/html',
				'wikitext={{AFC submission/comments|' + code + '}}&body_only=true',
				function (data) {
					deferred.resolve(data);
				}
			);

			return deferred;
		}

	});

}(AFCH, jQuery, mediaWiki));
//</nowiki>