MediaWiki:Gadget-afchelper.js/core.js

/* Uploaded from https://github.com/WPAFC/afch-rewrite, commit: fd9c10c62701c4a5f5e9c21d94c5aed3d5650537 (master) */ // /* * Copyright 2011 Twitter, Inc. *  Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */

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, 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 = '';

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. //   // 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);

( 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.api = new mw.Api;

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

// Must be defined above the larger $.extend block // because AFCH.consts.summaryAd depends on it			AFCH.consts.version = '0.9.1';

// Add more constants -- don't overwrite those already set, though AFCH.consts = $.extend( AFCH.consts, {				versionName: 'Imperial Ibex',

// If true, the script will NOT modify actual wiki content and // will instead mock all such API requests (success assumed) mockItUp: AFCH.consts.mockItUp || false,

// Full page name, "CelebWiki talk:Articles for creation/sandbox" pagename: mw.config.get( 'wgPageName' ).replace( /_/g, ' ' ),

// Link to the current page, "/wiki/CelebWiki talk:Articles for creation/sandbox" pagelink: mw.util.getUrl,

// Used when status is disabled nullstatus: { update: function { return; } },

// Current user user: mw.user.getName,

// Edit summary ad				summaryAd: ' (AFCH ' + AFCH.consts.version + ')',

// Require users to be on whitelist to use the script // Testwiki users don't need to be on it				whitelistRequired: mw.config.get( 'wgDBname' ) !== 'testwiki',

// Name of the whitelist page for reviewers whitelistTitle: 'CelebWiki:WikiProject Articles for creation/Participants' }, 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 = $( ' ' ) .append( 'If you wish to disable the helper script, ' ) .append( $( '' )								.text( 'click here' )								.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 ).postWithEditToken( {										action: 'options',										change: 'gadget-afchelper=0'									} ).done( function ( data ) {										mw.notify( 'AFCH has been disabled successfully. If you wish to re-enable it in the ' + 'future, you can do so via your Preferences by checking "Yet Another AFC Helper Script".' );									} ); } )							)							.append( '. ' );

// Otherwise, AFCH is probably installed via common.js/skin.js: // offer links for easy access. } else { $howToDisable = $( ' ' ) .append( 'If you wish to disable the helper script, you will need to manually ' +								'remove it from your ' ) .append( AFCH.makeLinkElementToPage( 'Special:MyPage/common.js', 'common.js' ) ) .append( ' or your ' ) .append( AFCH.makeLinkElementToPage( 'Special:MyPage/skin.js', 'skin.js' ) ) .append( 'page. ' ); }

// Finally, make and push the notification, then explode AFCH mw.notify(						$( ' ' )							.append( 'AFCH could not be loaded because "' + user + '" is not listed on ' )							.append( AFCH.makeLinkElementToPage( whitelist.rawTitle ) )							.append( '. You can request access to the AfC helper script there. ' )							.append( $howToDisable )							.append( 'If you have any questions or concerns, please ' )							.append( AFCH.makeLinkElementToPage( 'WT:AFCH', 'get in touch' ) )							.append( '!' ),						{							title: 'AFCH error: user not listed',							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; }

var promise = $.when;

if ( AFCH.consts.beta ) { // Load minified css mw.loader.load( AFCH.consts.scriptpath + '?action=raw&ctype=text/css&title=MediaWiki:Gadget-afchelper.css', 'text/css' ); promise = mw.loader.using( [					'jquery.chosen',					'jquery.spinner',					'jquery.ui',

'mediawiki.api', 'mediawiki.util', 'mediawiki.user' ] );			}

// And finally load the subscript promise.then( function {				$.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( 'CelebWiki talk:WikiProject Articles for creation/Helper script' ),				bugsLink: 'https://en.wikipedia.org/w/index.php?title=CelebWiki_talk:WikiProject_Articles_for_creation/Helper_script&action=edit&section=new',				bugsListLink: 'https://en.wikipedia.org/w/index.php?title=CelebWiki_talk:WikiProject_Articles_for_creation/Helper_script'			} ); $( ' ' )				.text( linkText || 'Give feedback!' ) .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 *		 * @param name */		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 {jQuery.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 ' } *                      }			 */			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. *						 * @param $v */						function parseValue( $v ) { var text = AFCH.jQueryToHtml( $v );

// Convert templates to look more template-y text = text.replace( / /g, '' ); text = text.replace( / /g, '|' );

// Expand embedded tags (like ) text = text.replace( new RegExp( ' (.*?)(?: .*?)*' + ' (.*?) (.*?)', '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.getShortDescription = function { return AFCH.api.get( {					action: 'query',					prop: 'description',					titles: this.rawTitle,					formatversion: 2				} ).then( function ( json ) {					return json.query.pages[ 0 ].description || '';				} ); };

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

// 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 *			 * @param textOnly * @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 {jQuery.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( 'Getting $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 };

$.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( 'Got $1' ); } else { deferred.reject( data ); // FIXME: get detailed error info from API result status.update( 'Error getting $1: ' + JSON.stringify( data ) ); }					} )					.fail( function ( err ) { deferred.reject( err ); status.update( 'Error getting $1: ' + JSON.stringify( err ) ); } );

return deferred; },

/**			 * Modifies a page's content * TODO the property name "contents" is quite silly, because people used to the MediaWiki API are gonna write "text" *			 * @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 || 'Editing' ) + ' $1...',						{ $1: AFCH.makeLinkElementToPage( pagename ) } ); } else { status = AFCH.consts.nullstatus; }

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

if ( pagename.indexOf( 'Draft:' ) === 0 ) { request.nocreate = 'true'; }

if ( options.minor ) { request.minor = 'true'; }

// 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( 'Edit to "' + pagename + '"', request ); deferred.resolve; return deferred; }

AFCH.api.postWithEditToken( 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( 'No changes made to $1' ); return; }

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

status.update( 'Saved $1 ' + AFCH.jQueryToHtml( $diffLink ) ); } else { deferred.reject( data ); // FIXME: get detailed error info from API result?? status.update( 'Error while saving $1: ' + JSON.stringify( data ) ); }					} )					.fail( function ( err ) { deferred.reject( err ); status.update( 'Error while saving $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 {jQuery.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 {jQuery.Deferred} Resolves with success/failure */			movePage: function ( oldTitle, newTitle, reason, additionalParameters, hide ) { var status, request, deferred = $.Deferred;

if ( !hide ) { status = new AFCH.status.Element( 'Moving $1 to $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.postWithEditToken( request ) // Move token === edit token .done( function ( data ) {						if ( data && data.move ) {							status.update( 'Moved $1 to $2' );							deferred.resolve( data.move );						} else {							// FIXME: get detailed error info from API result??							status.update( 'Error moving $1 to $2: ' + JSON.stringify( data.error ) );							deferred.reject( data.error );						}					} ) .fail( function ( err ) {						status.update( 'Error moving $1 to $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 * @param options * @return {jQuery.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

userTalkPage.exists.done( function ( exists ) {					userTalkPage.edit( { contents: ( exists ?  :  ) + '\n\n' + options.message, summary: options.summary || 'Notifying user', mode: 'appendtext', statusText: 'Notifying', hide: options.hide } )						.done( function { deferred.resolve; } )						.fail( function { deferred.reject; } );				} );

return deferred; },

/**			 * Logs a CSD nomination *			 * @param {Object} options *                 - title {string} *                 - reason {string} *                 - usersNotified {array} optional * @return {jQuery.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 log' ) );

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

logPage.getText.done( function ( logText ) {					var status,						date = new Date,						monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ],						headerRe = new RegExp( '^==+\\s*' + monthNames[ date.getMonth ] + '\\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=== ' + monthNames[ date.getMonth ] + ' ' + date.getUTCFullYear + ' ==='; }

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

if ( options.usersNotified && options.usersNotified.length ) { appendText += '; notified ';

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

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

logPage.edit( {						contents: appendText,						mode: 'appendtext',						summary: 'Logging speedy deletion nomination of ' + options.title + '',						statusText: 'Logging speedy deletion nomination to'					} ).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 {jQuery.Deferred} */			patrolRcid: function ( rcid, title ) { var request, deferred = $.Deferred, status = new AFCH.status.Element( 'Patrolling $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( 'Patrolled $1' );						deferred.resolve( data );					} else {						status.update( 'Failed to patrol $1: ' + JSON.stringify( data.patrol ) );						deferred.reject( data );					}				} ).fail( function ( data ) {					status.update( 'Failed to patrol $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 = $( ' ' ) .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 = $( '' ) .appendTo( AFCH.status.container );

this.update( initialText ); }		},

/**		 * A simple framework for getting/setting interface messages. * Not every message necessarily needs to go through here. But * it's nice to separate long messages from the code itself. *		 * @type {Object} */		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 {jQuery.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.postWithEditToken( {					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 = $( ' ' ); }

// 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: 'AFCH Preferences',					modal: true,					buttons: [						{							text: 'Cancel',							click: function {								prefs.$dialog.dialog( 'close' );							}						},						{							text: 'Save preferences',							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 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( 'AFCH: Preferences saved successfully! They will take effect when the current page is ' + 'reloaded or when you browse to another page.' );				} ); };

/**			 * 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 ) { $( ' ' )					.text( linkText || 'Update preferences' ) .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 . *		 * 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 = /\r?\n([\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 object * @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 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. // 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  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}  element */		makeLinkElementToPage: function ( pagename, displayTitle, newTab ) { var actualTitle = pagename.replace( /_/g, ' ' );

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

return $( '' ) .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  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}  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 = $( ' ' ).append( linkElement ), countSpanId = 'afch-cat-count-' + pagename .toLowerCase .replace( / /g, '-' ) .replace( /\//g, '-' );

linkSpan.append( $( ' ' ).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 ->  *		 * @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  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 = 'second'; } else if ( elapsed < msPerHour ) { amount = Math.round( elapsed / msPerMinute ); unit = 'minute'; } else if ( elapsed < msPerDay ) { amount = Math.round( elapsed / msPerHour ); unit = 'hour'; } else if ( elapsed < msPerMonth ) { amount = Math.round( elapsed / msPerDay ); unit = 'day'; } else if ( elapsed < msPerYear ) { amount = Math.round( elapsed / msPerMonth ); unit = 'month'; } else { amount = Math.round( elapsed / msPerYear ); unit = 'year'; }

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

return [ amount, unit, 'ago' ].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 $( ' ' ).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 * @param mwstyle * @return {Date|integer} */		parseForTimestamp: function ( string, mwstyle ) { var exp, match, date;

exp = new RegExp( '(\\d{1,2}):(\\d{2}), (\\d{1,2}) ' +				'(January|February|March|April|May|June|July|August|September|October|November|December) ' +				'(\\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 {jQuery.Deferred} Resolves with the requested HTML */		getReason: function ( code ) { var deferred = $.Deferred;

$.post( 'https://en.wikipedia.org/api/rest_v1/transform/wikitext/to/html',				'wikitext=&body_only=true',				function ( data ) {					deferred.resolve( data );				}			);

return deferred; }

} );

}( AFCH, jQuery, mediaWiki ) ); //