User:Novem Linguae/Scripts/GANReviewTool.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>

// === Compiled with Novem Linguae's publish.php script ======================

$(async function() {

// === GANReviewTool.js ======================================================

// 

// Move these to a bootstrap file?

$(async function() {
	await mw.loader.using(['mediawiki.api'], async () => {
		let ganController = new GANReviewController();
		await ganController.execute(
			$,
			mw,
			location,
			new GANReviewWikicodeGenerator(),
			new GANReviewHTMLGenerator()
		);

		let garController = new GARCloserController();
		await garController.execute(
			$,
			mw,
			location,
			new GARCloserWikicodeGenerator(),
			new GARCloserHTMLGenerator()
		);

		let massGARController = new MassGARController();
		await massGARController.execute(
			$,
			mw,
			new MassGARWikicodeGenerator(),
			new GARCloserController(),
			new GARCloserWikicodeGenerator()
		);

		// TODO: extract API calls into a MediaWikiApi class, which is essentially the model. can be 1 class used by both GANReviewController and GARCloserController
		// TODO: extract $ and location into a Browser class, which is essentially the view. maybe also merge HTMLGenerators into this class
		// TODO: selenium tests
	});
});

// 

// === modules/GANReviewController.js ======================================================


class GANReviewController {
	/**
	 * @param {jQuery} $ jQuery
	 * @param {mw} mw mediawiki, https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw
	 * @param {Location} location https://developer.mozilla.org/en-US/docs/Web/API/Window/location
	 * @param {GANReviewWikicodeGenerator} wg
	 * @param {GANReviewHTMLGenerator} hg
	 */
	async execute( $, mw, location, wg, hg ) {
		this.$ = $;
		this.mw = mw;
		this.location = location;
		this.wg = wg;
		this.hg = hg;

		this.ganReviewPageTitle = this.mw.config.get( 'wgPageName' ); // includes namespace, underscores instead of spaces
		this.ganReviewPageTitle = this.ganReviewPageTitle.replace( /_/g, ' ' ); // underscores to spaces. prevents some bugs later

		if ( !this.shouldRunOnThisPageQuickChecks( this.ganReviewPageTitle ) ) {
			return;
		}
		if ( !await this.shouldRunOnThisPageSlowChecks() ) {
			return;
		}

		await this.displayForm();
		await this.listenForUncollapse();
		this.handleUserChangingFormType();

		this.$( '#GANReviewTool-Submit' ).on( 'click', async () => {
			await this.clickSubmit();
		} );
	}

	async clickSubmit() {
		this.readFormAndSetVariables();

		const hasFormValidationErrors = this.validateForm();
		if ( hasFormValidationErrors ) {
			return;
		}

		this.$( '#GANReviewTool-Form' ).hide();
		this.$( '#GANReviewTool-ProcessingMessage' ).show();

		this.editSummarySuffix = ' ([[User:Novem Linguae/Scripts/GANReviewTool|GANReviewTool]])';
		this.reviewTitle = this.ganReviewPageTitle;
		this.error = false;
		try {
			if ( this.action === 'pass' ) {
				await this.doPass();
			} else if ( this.action === 'fail' ) {
				await this.doFail();
			} else if ( this.action === 'placeOnHold' ) {
				await this.placeOnHold();
			} else if ( this.action === 'askSecondOpinion' ) {
				await this.askSecondOpinion();
			} else if ( this.action === 'answerSecondOpinion' ) {
				await this.answerSecondOpinion();
			}
			await this.writeToLog();
			this.pushStatus( 'Script complete. Refreshing page.' );
			// TODO: 1 second delay?
			location.reload();
		} catch ( err ) {
			// Documentation for the http error is in the "response" section of https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.Api-method-ajax
			this.pushStatus( `<span class="GANReviewTool-ErrorNotice">An error occurred :( Details: ${ err }</span>` );
			this.error = err;
			// this.editSummary += ' cc [[User:Novem Linguae]]';
			await this.writeToLog();
		}
	}

	async placeOnHold() {
		this.editSummary = `placed [[${ this.gaTitle }]] GAN nomination on hold` + this.editSummarySuffix;

		await this.processOnHoldForTalkPage();
	}

	async askSecondOpinion() {
		this.editSummary = `asked for a 2nd opinion regarding [[${ this.gaTitle }]] GAN nomination` + this.editSummarySuffix;

		await this.processAskSecondOpinionForTalkPage();
	}

	async answerSecondOpinion() {
		this.editSummary = `answered 2nd opinion request regarding [[${ this.gaTitle }]] GAN nomination` + this.editSummarySuffix;

		await this.processAnswerSecondOpinionForTalkPage();
	}

	async processOnHoldForTalkPage() {
		this.pushStatus( 'Marking article talk page status as "on hold"' );
		let talkWikicode = await this.getWikicode( this.gaTalkTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		talkWikicode = this.wg.getOnHoldWikicodeForTalkPage( talkWikicode );
		this.talkRevisionID = await this.makeEdit( this.gaTalkTitle, this.editSummary, talkWikicode );
	}

	async processAskSecondOpinionForTalkPage() {
		this.pushStatus( 'Marking article talk page status as "asking for a second opinion"' );
		let talkWikicode = await this.getWikicode( this.gaTalkTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		talkWikicode = this.wg.getAskSecondOpinionWikicodeForTalkPage( talkWikicode );
		this.talkRevisionID = await this.makeEdit( this.gaTalkTitle, this.editSummary, talkWikicode );
	}

	async processAnswerSecondOpinionForTalkPage() {
		this.pushStatus( 'Marking article talk page status as "answered second opinion request (onreview)"' );
		let talkWikicode = await this.getWikicode( this.gaTalkTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		talkWikicode = this.wg.getAnswerSecondOpinionWikicodeForTalkPage( talkWikicode );
		this.talkRevisionID = await this.makeEdit( this.gaTalkTitle, this.editSummary, talkWikicode );
	}

	/**
	 * @return {boolean} hasFormValidationErrors
	 */
	validateForm() {
		this.$( '.GANReviewTool-ValidationError' ).hide();

		let hasFormValidationErrors = false;

		// if pass, a WP:GA subpage heading must be selected
		if ( this.action === 'pass' && !this.detailedTopic ) {
			this.$( '#GANReviewTool-NoTopicMessage' ).show();
			hasFormValidationErrors = true;
		}

		// "Wikicode to display" text box must not contain a pipe. Prevents this kind of thing from being written to the [[WP:GA]] subpages: [[HMS Resistance (1801)|HMS Resistance (1801)|HMS ''Resistance'' (1801)]]
		if ( this.$( '[name="GANReviewTool-DisplayWikicode"]' ).val().includes( '|' ) ) {
			this.$( '#GANReviewTool-NoPipesMessage' ).show();
			hasFormValidationErrors = true;
		}

		return hasFormValidationErrors;
	}

	async doPass() {
		this.editSummary = `promote [[${ this.gaTitle }]] to good article` + this.editSummarySuffix;
		this.gaSubpageShortTitle = this.$( '[name="GANReviewTool-Topic"]' ).val();

		if ( this.needsATOP ) {
			await this.processPassForGANPage();
		}
		await this.processPassForTalkPage();
		await this.processPassForGASubPage();
	}

	async doFail() {
		this.editSummary = `close [[${ this.gaTitle }]] good article nomination as unsuccessful` + this.editSummarySuffix;

		if ( this.needsATOP ) {
			await this.processFailForGANPage();
		}
		await this.processFailForTalkPage();
	}

	async processFailForGANPage() {
		this.pushStatus( 'Placing {{atop}} and {{abot}} on GA review page.' );
		let reviewWikicode = await this.getWikicode( this.ganReviewPageTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		reviewWikicode = this.wg.getFailWikicodeForGANPage( reviewWikicode );
		this.reviewRevisionID = await this.makeEdit( this.reviewTitle, this.editSummary, reviewWikicode );
	}

	async processFailForTalkPage() {
		this.pushStatus( 'Deleting {{GA nominee}} from article talk page.' );
		this.pushStatus( 'Adding {{FailedGA}} or {{Article history}} to article talk page.' );
		let talkWikicode = await this.getWikicode( this.gaTalkTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		let oldid;
		try {
			oldid = await this.getRevisionIDOfNewestRevision( this.gaTitle );
		} catch ( err ) {
			throw new Error( 'Unable to get main article\'s newest revision ID for placement in the |oldid= parameter of the talk page template. Is the main article created yet?' );
		}
		talkWikicode = this.wg.getFailWikicodeForTalkPage( talkWikicode, this.reviewTitle, oldid );
		this.talkRevisionID = await this.makeEdit( this.gaTalkTitle, this.editSummary, talkWikicode );
	}

	async processPassForTalkPage() {
		this.pushStatus( 'Deleting {{GA nominee}} from article talk page.' );
		this.pushStatus( 'Adding {{GA}} or {{Article history}} to article talk page.' );
		this.pushStatus( 'Changing WikiProject template class parameters to GA on article talk page.' );
		let talkWikicode = await this.getWikicode( this.gaTalkTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		let oldid;
		try {
			oldid = await this.getRevisionIDOfNewestRevision( this.gaTitle );
		} catch ( err ) {
			throw new Error( 'Unable to get main article\'s newest revision ID for placement in the |oldid= parameter of the talk page template. Is the main article created yet?' );
		}
		talkWikicode = this.wg.getPassWikicodeForTalkPage( talkWikicode, this.reviewTitle, this.gaSubpageShortTitle, oldid );
		this.talkRevisionID = await this.makeEdit( this.gaTalkTitle, this.editSummary, talkWikicode );
	}

	async processPassForGASubPage() {
		this.pushStatus( 'Adding to appropriate subpage of [[WP:GA]]' );
		const gaSubpageLongTitle = 'Wikipedia:Good articles/' + this.gaSubpageShortTitle;
		const gaDisplayTitle = this.$( '[name="GANReviewTool-DisplayWikicode"]' ).val();

		let gaSubpageWikicode;
		try {
			gaSubpageWikicode = await this.getWikicode( gaSubpageLongTitle );
		} catch ( err ) {
			throw new Error( 'Error getting GA subpage wikicode. Is this GA subpage created yet?' );
		}

		gaSubpageWikicode = this.wg.getPassWikicodeForGAListPage( this.detailedTopic, gaSubpageWikicode, this.gaTitle, gaDisplayTitle );
		const gaSubPageEditSummary = this.getGASubPageEditSummary( this.editSummary, this.detailedTopic );
		this.gaRevisionID = await this.makeEdit( gaSubpageLongTitle, gaSubPageEditSummary, gaSubpageWikicode );
	}

	async processPassForGANPage() {
		this.pushStatus( 'Placing {{atop}} and {{abot}} on GA review page.' );
		let reviewWikicode = await this.getWikicode( this.ganReviewPageTitle ); // get this wikicode again, in case it changed between page load and "submit" button click
		reviewWikicode = this.wg.getPassWikicodeForGANPage( reviewWikicode );
		this.reviewRevisionID = await this.makeEdit( this.reviewTitle, this.editSummary, reviewWikicode );
	}

	async getRevisionIDOfNewestRevision( pageTitle ) {
		const api = new this.mw.Api();
		const params = {
			action: 'query',
			format: 'json',
			prop: 'revisions',
			titles: pageTitle,
			formatversion: '2',
			rvlimit: '1',
			rvdir: 'older'
		};
		const result = await api.post( params );
		const revisionID = result.query.pages[ 0 ].revisions[ 0 ].revid;
		return revisionID;
	}

	readFormAndSetVariables() {
		this.action = this.$( '[name="GANReviewTool-PassOrFail"]:checked' ).val();
		this.needsATOP = this.$( '[name="GANReviewTool-ATOPYesNo"]' ).is( ':checked' );
		this.detailedTopic = document.querySelector( '[name="GANReviewTool-Topic"]' ); // TODO: change this to jquery, so less dependencies, more unit testable
		this.detailedTopic = this.detailedTopic.options[ this.detailedTopic.selectedIndex ];
		this.detailedTopic = this.detailedTopic.text;
	}

	/**
	 * Show or hide different parts of the form depending on whether the user clicks pass or fail.
	 */
	handleUserChangingFormType() {
		this.$( '[name="GANReviewTool-PassOrFail"]' ).on( 'change', () => {
			if ( this.$( '[name="GANReviewTool-PassOrFail"]:checked' ).val() === 'pass' ) {
				this.$( '#GANReviewTool-PassDiv' ).show();
				this.$( '#GANReviewTool-PassFailDiv' ).show();
			} else if ( this.$( '[name="GANReviewTool-PassOrFail"]:checked' ).val() === 'fail' ) {
				this.$( '#GANReviewTool-PassDiv' ).hide();
				this.$( '#GANReviewTool-NoTopicMessage' ).hide();
				this.$( '#GANReviewTool-PassFailDiv' ).show();
			} else { // onHold, askSecondOpinion, answerSecondOpinion
				this.$( '#GANReviewTool-PassDiv' ).hide();
				this.$( '#GANReviewTool-NoTopicMessage' ).hide();
				this.$( '#GANReviewTool-PassFailDiv' ).hide();
			}
		} );
	}

	async listenForUncollapse() {
		this.$( '#GANReviewTool-Uncollapse' ).on( 'click', () => {
			this.$( '.GANReviewTool-Collapsed' ).hide();
			this.$( '#GANReviewTool-MainForm' ).show();
		} );
	}

	async displayForm() {
		// split this query in two, to avoid the 12MB limit
		const obj1 = await this.getWikicodeForMultiplePages( [
			'Wikipedia:Good articles/Agriculture, food and drink',
			'Wikipedia:Good articles/Art and architecture',
			'Wikipedia:Good articles/Engineering and technology',
			'Wikipedia:Good articles/Geography and places',
			'Wikipedia:Good articles/History',
			'Wikipedia:Good articles/Language and literature',
			'Wikipedia:Good articles/Mathematics'
		] );

		const obj2 = await this.getWikicodeForMultiplePages( [
			'Wikipedia:Good articles/Media and drama',
			'Wikipedia:Good articles/Music',
			'Wikipedia:Good articles/Natural sciences',
			'Wikipedia:Good articles/Philosophy and religion',
			'Wikipedia:Good articles/Social sciences and society',
			'Wikipedia:Good articles/Sports and recreation',
			'Wikipedia:Good articles/Video games',
			'Wikipedia:Good articles/Warfare'
		] );

		const wikicodeOfGASubPages = { ...obj1, ...obj2 };

		const html = this.hg.getHTML( this.gaTitle, wikicodeOfGASubPages );
		this.$( '#mw-content-text' ).prepend( html );
	}

	async shouldRunOnThisPageSlowChecks() {
		// only run if this review hasn't already been closed. check for {{atop}}
		const reviewWikicode = await this.getWikicode( this.ganReviewPageTitle );
		if ( reviewWikicode.match( /\{\{atop/i ) ) {
			return false;
		}

		// only run if talk page has {{GA nominee}}
		this.gaTitle = this.getGATitle( this.ganReviewPageTitle );
		this.gaTalkTitle = this.getGATalkTitle( this.gaTitle );
		const talkWikicode = await this.getWikicode( this.gaTalkTitle );
		if ( !talkWikicode.match( /\{\{GA nominee/i ) ) {
			return false;
		}

		return true;
	}

	async writeToLog() {
		// always log no matter what. hopefully log some errors so I can fix them
		this.pushStatus( 'Adding to log' );
		const username = this.mw.config.get( 'wgUserName' );
		const textToAppend = this.wg.getLogMessageToAppend( username, this.action, this.reviewTitle, this.reviewRevisionID, this.talkRevisionID, this.gaRevisionID, this.error );
		await this.appendToPage( 'User:Novem Linguae/Scripts/GANReviewTool/GANReviewLog', this.editSummary, textToAppend );
	}

	async getWikicode( title ) {
		const api = new this.mw.Api();
		const params = {
			action: 'parse',
			page: title,
			prop: 'wikitext',
			format: 'json'
		};
		const result = await api.post( params );
		if ( result.error ) {
			return '';
		}
		const wikicode = result.parse.wikitext[ '*' ];
		return wikicode;
	}

	/**
	 * @param {Array} listOfTitles
	 * @return {Promise<Object>} {'Page title': 'Page wikicode', 'Page title2': 'Page wikicode2'} Maximum 12MB of result wikicode. Will cut off after that.
	 */
	async getWikicodeForMultiplePages( listOfTitles ) {
		const api = new this.mw.Api();
		const params = {
			action: 'query',
			format: 'json',
			prop: 'revisions',
			titles: listOfTitles.join( '|' ),
			formatversion: '2',
			rvprop: 'content'
		};
		const result = await api.post( params );
		if ( result.error ) {
			return '';
		}
		const simplified = this.simplifyQueryRevisionsObject( result );

		return simplified;
	}

	/**
	 * convert from the complex format returned by API action=query&prop=revisions, to
	 * {'Page title': 'Page wikicode', 'Page title2': 'Page wikicode2'}
	 */
	simplifyQueryRevisionsObject( queryRevisionsObject ) {
		const pages = queryRevisionsObject.query.pages;
		let newFormat = {};
		for ( const page of pages ) {
			if ( page.missing ) {
				continue;
			} // on testwiki, these pages may not be created yet. just skip em
			const key = page.title;
			const value = page.revisions[ 0 ].content;
			newFormat[ key ] = value;
		}
		newFormat = this.alphabetizeObjectByKeys( newFormat );
		return newFormat;
	}

	/**
	 * Mathias Bynens, CC BY-SA 4.0, https://stackoverflow.com/a/31102605/3480193
	 */
	alphabetizeObjectByKeys( unordered ) {
		return Object.keys( unordered ).sort().reduce(
			( obj, key ) => {
				obj[ key ] = unordered[ key ];
				return obj;
			},
			{}
		);
	}

	async makeEdit( title, editSummary, wikicode ) {
		const api = new this.mw.Api();
		const params = {
			action: 'edit',
			format: 'json',
			title: title,
			text: wikicode,
			summary: editSummary
		};
		const result = await api.postWithToken( 'csrf', params );
		const revisionID = result.edit.newrevid;
		return revisionID;
	}

	/**
	 * Lets you append without getting the Wikicode first. Saves an API query.
	 */
	async appendToPage( title, editSummary, wikicodeToAppend ) {
		const api = new this.mw.Api();
		const params = {
			action: 'edit',
			format: 'json',
			title: title,
			appendtext: wikicodeToAppend,
			summary: editSummary
		};
		const result = await api.postWithToken( 'csrf', params );
		const revisionID = result.edit.newrevid;
		return revisionID;
	}

	pushStatus( statusToAdd ) {
		this.$( '#GANReviewTool-ProcessingMessage > p' ).append( '<br />' + statusToAdd );
	}

	shouldRunOnThisPageQuickChecks( title ) {
		// don't run when not viewing articles
		const action = this.mw.config.get( 'wgAction' );
		if ( action !== 'view' ) {
			return false;
		}

		// don't run when viewing diffs
		const isDiff = this.mw.config.get( 'wgDiffNewId' );
		if ( isDiff ) {
			return false;
		}

		const isDeletedPage = ( !this.mw.config.get( 'wgCurRevisionId' ) );
		if ( isDeletedPage ) {
			return false;
		}

		// only run in talk namespace
		const namespace = this.mw.config.get( 'wgNamespaceNumber' );
		const isTalkNamespace = ( namespace === 1 );
		if ( !isTalkNamespace ) {
			return false;
		}

		// only run on pages that end in /GA##
		if ( !this.isGASubPage( title ) ) {
			return false;
		}

		return true;
	}

	isGASubPage( title ) {
		return Boolean( title.match( /\/GA\d{1,2}$/ ) );
	}

	getGATitle( title ) {
		title = title.replace( 'Talk:', '' );
		title = title.replace( /_/g, ' ' );
		title = title.replace( /\/[^/]+$/, '' ); // chop off /GA1 from the end of title
		return title;
	}

	getGATalkTitle( gaTitle ) {
		return 'Talk:' + gaTitle;
	}

	/**
	 * @param {string} editSummary
	 * @param {string} detailedTopic The heading name, with leading and trailing === to denote it as a heading
	 */
	getGASubPageEditSummary( editSummary, detailedTopic ) {
		// remove heading syntax == and trim
		detailedTopic = detailedTopic.match( /={2,6} ?(.+?) ?={2,6}/ )[ 1 ];

		// remove '' style formatting, this should not go in the anchor. #''Test'' should be #Test
		detailedTopic = detailedTopic.replace( /'{2,}/g, '' );

		// prepend /* heading */
		editSummary = `/* ${ detailedTopic } */ ${ editSummary }`;

		return editSummary;
	}
}


// === modules/GANReviewHTMLGenerator.js ======================================================

class GANReviewHTMLGenerator {
	getHTML( gaTitle, wikicodeOfGASubPages ) {
		let defaultDisplayText = this.getDefaultDisplayText( gaTitle );
		defaultDisplayText = this.escapeHtml( defaultDisplayText );

		const gaTopicComboBoxOptionsHTML = this.makeTopicComboBoxOptions( wikicodeOfGASubPages );

		return `
<style>
	#GANReviewTool {
		border: 1px solid black;
		padding: 1em;
		margin-bottom: 1em;
	}

	#GANReviewTool h2 {
		margin-top: 0;
	}

	#GANReviewTool strong {
		text-decoration: underline;
	}

	#GANReviewTool code {
		/* font-family: monospace; */
	}

	#GANReviewTool input[type="text"] {
		width: 50em;
	}

	#GANReviewTool p {
		margin-top: 1.5em;
		margin-bottom: 1.5em;
		line-height: 1.5em;
	}

	#GANReviewTool option:disabled {
		font-weight: bold;
		color: green;
	}

	#GANReviewTool-ProcessingMessage {
		display: none;
	}

	.GANReviewTool-ValidationError {
		display: none;
		color: red;
		font-weight: bold;
	}

	.GANReviewTool-ErrorNotice {
		color: red;
		font-weight: bold;
	}

	#GANReviewTool-MainForm {
		display: none;
	}
</style>

<div id="GANReviewTool">
	<div id="GANReviewTool-Form">
		<h2>
			GAN Review Tool
		</h2>

		<p class="GANReviewTool-Collapsed">
			<a id="GANReviewTool-Uncollapse">Click here</a> to open GANReviewTool.
		</p>

		<div id="GANReviewTool-MainForm">
			<p>
				<strong>Action</strong><br />
				<input type="radio" name="GANReviewTool-PassOrFail" value="pass" checked /> Pass<br />
				<input type="radio" name="GANReviewTool-PassOrFail" value="fail" /> Fail<br />
				<input type="radio" name="GANReviewTool-PassOrFail" value="placeOnHold" /> Place On Hold<br />
				<input type="radio" name="GANReviewTool-PassOrFail" value="askSecondOpinion" /> Ask 2nd Opinion<br />
				<input type="radio" name="GANReviewTool-PassOrFail" value="answerSecondOpinion" /> Answer 2nd Opinion<br />
			</p>

			<!-- if pass or fail -->
			<div id="GANReviewTool-PassFailDiv">
				<p>
					<input type="checkbox" name="GANReviewTool-ATOPYesNo" value="1" checked /> Place {{<a href="/wiki/Template:Archive_top">Archive top</a>}} and {{Archive bottom}} templates on GA review page
				</p>

				<!-- if pass -->
				<div id="GANReviewTool-PassDiv">
					<p>
						<strong>Topic, subtopic, and sub-subtopic:</strong><br />
						<select name="GANReviewTool-Topic">
							<option></option>

${ gaTopicComboBoxOptionsHTML }
						</select>
					</p>

					<p>
						<strong>Wikicode to display when adding this to the list of good articles at [[<a href="/wiki/Wikipedia:Good_articles">WP:GA</a>]]</strong><br />
						People should be in format: <code>Lastname, Firstname</code><br />
						Albums, television shows, <a href="/wiki/Genus">genus</a>, <a href="/wiki/Binomial_nomenclature">species</a>, court cases should be italicized: <code>''Jeopardy''</code><br />
						Television episodes should be surrounded by double quotes: <code>"Episode name"</code><br />
						Parentheses at the end should not be formatted: <code>''Revolver'' (Beatles album)</code><br />
						Artwork, poetry, etc. may also require special formatting<br />
						More info at [[<a href="/wiki/Wikipedia:Manual_of_Style/Titles_of_works#Italics">MOS:TITLE#Italics</a>]] and [[<a href="/wiki/Wikipedia:Manual_of_Style/Titles_of_works#Quotation_marks">MOS:TITLE#Quotation marks</a>]]<br />
						<input type="text" name="GANReviewTool-DisplayWikicode" value="${ defaultDisplayText }" />
					</p>
				</div>
				<!-- endif -->
			</div>
			<!-- endif -->

			<p>
				<button id="GANReviewTool-Submit">Submit</button>
			</p>

			<div id="GANReviewTool-NoTopicMessage" class="GANReviewTool-ValidationError">
				You must select a topic from the combo box above.
			</div>

			<div id="GANReviewTool-NoPipesMessage" class="GANReviewTool-ValidationError">
				"Wikicode to display" should not contain a pipe "|"
			</div>
		</div>
	</div>

	<div id="GANReviewTool-ProcessingMessage">
		<p>
			Processing...
		</p>
	</div>
</div>
`;
	}

	/**
	 * CC BY-SA 4.0, bjornd, https://stackoverflow.com/a/6234804/3480193
	 */
	escapeHtml( unsafe ) {
		return unsafe
			.replace( /&/g, '&amp;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' )
			.replace( /"/g, '&quot;' )
			.replace( /'/g, '&#039;' );
	}

	getDefaultDisplayText( gaTitle ) {
		const endsWithParentheticalDisambiguator = gaTitle.match( /^.+ \(.+\)$/ );
		if ( !endsWithParentheticalDisambiguator ) {
			return gaTitle;
		}

		const suffixesThatTriggerItalics = [
			'album',
			'book',
			'comic',
			'comics',
			'film series',
			'film',
			'magazine',
			'manga',
			'novel',
			'painting',
			'poem',
			'sculpture',
			'season 1',
			'season 10',
			'season 2',
			'season 3',
			'season 4',
			'season 5',
			'season 6',
			'season 7',
			'season 8',
			'season 9',
			'series 1',
			'series 10',
			'series 2',
			'series 3',
			'series 4',
			'series 5',
			'series 6',
			'series 7',
			'series 8',
			'series 9',
			'soundtrack'
		];
		const suffixesThatTriggerDoubleQuotes = [
			'song'
		];
		const suffixesThatTriggerDoubleQuotesAndItalics = [
			'30 Rock',
			'Family Guy',
			'Fringe',
			'Glee',
			'Lost',
			'Parks and Recreation',
			'South Park',
			'Star Trek: Enterprise',
			'Star Trek: The Next Generation',
			'The Office',
			'The Simpsons',
			'The Walking Dead',
			'The X-Files'
		];

		const firstHalf = gaTitle.match( /^(.+) \((.+)\)$/ )[ 1 ];
		const secondHalf = gaTitle.match( /^(.+) \((.+)\)$/ )[ 2 ];

		for ( const suffixToCheck of suffixesThatTriggerItalics ) {
			if ( gaTitle.endsWith( suffixToCheck + ')' ) ) {
				return `''${ firstHalf }'' (${ secondHalf })`;
			}
		}

		for ( const suffixToCheck of suffixesThatTriggerDoubleQuotes ) {
			if ( gaTitle.endsWith( suffixToCheck + ')' ) ) {
				return `"${ firstHalf }" (${ secondHalf })`;
			}
		}

		for ( const suffixToCheck of suffixesThatTriggerDoubleQuotesAndItalics ) {
			if ( gaTitle.endsWith( suffixToCheck + ')' ) ) {
				return `"${ firstHalf }" (''${ secondHalf }'')`;
			}
		}

		return gaTitle;
	}

	makeTopicComboBoxOptions( wikicodeOfGASubPages ) {
		let html = '';
		for ( const key in wikicodeOfGASubPages ) {
			const topic = key.replace( /^Wikipedia:Good articles\//, '' );
			const wikicode = wikicodeOfGASubPages[ key ];
			html += this.makeTopicComboBoxOptionGroup( wikicode, topic );
		}
		return html;
	}

	makeTopicComboBoxOptionGroup( wikicode, topic ) {
		// delete some stuff that throws it off
		wikicode = wikicode.replace( /\s*\[\[File:[^\]]+\]\]\s*/gi, '' );
		wikicode = wikicode.replace( /\{\{(?!#invoke)[^}]+\}\}\s*\n/gi, '' );
		wikicode = wikicode.replace( /={2,}\s*Contents\s*={2,}\s*\n/gi, '' );
		wikicode = wikicode.replace( /<!--[^>]+>\s*\n/gi, '' );

		// search for `==Headings==\n` not followed by `{{#invoke`, and replace with a disabled <option>
		// 							<option value="Art and architecture" disabled>==Art and architecture==</option>
		wikicode = wikicode.replace(
			/(={2,}\s*[^=]+\s*={2,})\n(?!\{\{#invoke)/gi,
			`							<option value="${ topic }" disabled>$1</option>\n`
		);

		// search for `==Headings==\n{{#invoke`, and replace with a non-disabled <option>
		// 							<option value="Art and architecture">==Art and architecture==</option>
		wikicode = wikicode.replace(
			/(={2,}\s*[^=]+\s*={2,})\n(?=\{\{#invoke)/gi,
			`							<option value="${ topic }">$1</option>\n`
		);

		// delete any line that isn't an <option>
		wikicode = wikicode.replace( /^(?!\t{7}<option).*\n/gim, '' );

		// turn === Heading === into ===Heading=== (delete spaces)
		wikicode = wikicode.replace( /\s+=/gi, '=' );
		wikicode = wikicode.replace( /=\s+/gi, '=' );

		// add newline to the end of this group
		wikicode += '\n';

		return wikicode;
	}
}


// === modules/GANReviewWikicodeGenerator.js ======================================================

class GANReviewWikicodeGenerator {
	getPassWikicodeForGANPage( reviewWikicode ) {
		return this.placeATOP( reviewWikicode, 'Passed. ~~~~', 'green' );
	}

	getPassWikicodeForTalkPage( talkWikicode, reviewTitle, topic, oldid ) {
		// Delete {{GA nominee}} from article talk page.
		const gaPageNumber = this.getTemplateParameter( talkWikicode, 'GA nominee', 'page' );
		talkWikicode = this.deleteGANomineeTemplate( talkWikicode );

		// Add {{GA}} or {{Article history}} to article talk page.
		const boolHasArticleHistoryTemplate = this.hasArticleHistoryTemplate( talkWikicode );
		if ( boolHasArticleHistoryTemplate ) {
			talkWikicode = this.updateArticleHistory( talkWikicode, topic, reviewTitle, 'listed', oldid );
		} else {
			talkWikicode = this.addGATemplate( talkWikicode, topic, gaPageNumber, oldid );
		}

		// Change WikiProject template class parameters to GA on article talk page.
		talkWikicode = this.changeWikiProjectArticleClassToGA( talkWikicode );

		return talkWikicode;
	}

	getPassWikicodeForGAListPage( gaSubpageHeading, gaSubpageWikicode, gaTitle, gaDisplayTitle ) {
		gaDisplayTitle = gaDisplayTitle.trim();
		this.findSectionStartAndEnd( gaSubpageHeading, gaSubpageWikicode );
		const insertPosition = this.findAlphabeticalInsertPosition( gaSubpageWikicode, gaDisplayTitle );
		const wikicodeToInsert = this.getWikicodeToInsert( gaTitle, gaDisplayTitle );
		return this.insertStringIntoStringAtPosition( gaSubpageWikicode, wikicodeToInsert, insertPosition );
	}

	getFailWikicodeForGANPage( reviewWikicode ) {
		return this.placeATOP( reviewWikicode, 'Unsuccessful. ~~~~', 'red' );
	}

	getFailWikicodeForTalkPage( talkWikicode, reviewTitle, oldid ) {
		// Deleting {{GA nominee}} from article talk page.
		const topic = this.getTopicFromGANomineeTemplate( talkWikicode );
		const gaPageNumber = this.getTemplateParameter( talkWikicode, 'GA nominee', 'page' );
		talkWikicode = this.deleteGANomineeTemplate( talkWikicode );

		// Adding {{FailedGA}} or {{Article history}} to article talk page.
		// TODO: get top revision ID of main article, pass it into below functions, have it add the revision ID
		const boolHasArticleHistoryTemplate = this.hasArticleHistoryTemplate( talkWikicode );
		if ( boolHasArticleHistoryTemplate ) {
			talkWikicode = this.updateArticleHistory( talkWikicode, topic, reviewTitle, 'failed', oldid );
		} else {
			talkWikicode = this.addFailedGATemplate( talkWikicode, topic, gaPageNumber, oldid );
		}

		return talkWikicode;
	}

	getOnHoldWikicodeForTalkPage( talkWikicode ) {
		return this.changeGANomineeTemplateStatus( talkWikicode, 'onhold' );
	}

	getAskSecondOpinionWikicodeForTalkPage( talkWikicode ) {
		return this.changeGANomineeTemplateStatus( talkWikicode, '2ndopinion' );
	}

	getAnswerSecondOpinionWikicodeForTalkPage( talkWikicode ) {
		return this.changeGANomineeTemplateStatus( talkWikicode, 'onreview' );
	}

	findSectionStartAndEnd( gaSubpageHeading, gaSubpageWikicode ) {
		// find heading
		const headingStartPosition = this.getGASubpageHeadingPosition( gaSubpageHeading, gaSubpageWikicode );
		// now move down a bit, to the first line with an item. skip {{Further}}, {{#invoke:Good Articles|subsection|, etc.
		this.subsectionStartPosition = this.findFirstStringAfterPosition( '|subsection|\n', gaSubpageWikicode, headingStartPosition ) + 13;
		this.headingEndPosition = this.findFirstStringAfterPosition( '\n}}', gaSubpageWikicode, headingStartPosition ) + 1;
		// Make sure we found the right start position, and not the section below.
		if ( this.subsectionStartPosition > this.headingEndPosition ) {
			throw new Error( 'getPassWikicodeForGAListPage: Unable to find |subheading|\\n' );
		}
	}

	findAlphabeticalInsertPosition( gaSubpageWikicode, gaDisplayTitle ) {
		let insertPosition;
		let startOfLine = this.subsectionStartPosition;
		while ( startOfLine < this.headingEndPosition ) {
			const endOfLine = this.findFirstStringAfterPosition( '\n', gaSubpageWikicode, startOfLine );
			const line = gaSubpageWikicode.slice( startOfLine, endOfLine );
			const lineWithSomeFormattingRemoved = this.removeFormattingThatInterferesWithSort( line );
			const displayTitleWithSomeFormattingRemoved = this.removeFormattingThatInterferesWithSort( gaDisplayTitle );
			if ( !this.aSortsLowerThanB( lineWithSomeFormattingRemoved, displayTitleWithSomeFormattingRemoved ) ) {
				insertPosition = startOfLine;
				break;
			}
			startOfLine = endOfLine + 1;
		}
		if ( !insertPosition ) {
			insertPosition = this.headingEndPosition;
		}
		return insertPosition;
	}

	changeGANomineeTemplateStatus( talkWikicode, newStatus ) {
		// already has correct status
		const regex = new RegExp( `({{GA nominee[^\\}]*\\|\\s*status\\s*=\\s*${ newStatus })`, 'i' );
		const alreadyHasCorrectStatus = talkWikicode.match( regex );
		if ( alreadyHasCorrectStatus ) {
			return talkWikicode;
		}

		// has a status, but needs to be changed
		const hasStatus = talkWikicode.match( /({{GA nominee[^}]*\|\s*status\s*=\s*)[^}|]*/i );
		if ( hasStatus ) {
			return talkWikicode.replace( /({{GA nominee[^}]*\|\s*status\s*=\s*)[^}|]*/i, `$1${ newStatus }` );
		}

		// if no old status, insert new status
		return talkWikicode.replace( /({{GA nominee[^}]*)(}})/i, `$1|status=${ newStatus }$2` );
	}

	getLogMessageToAppend( username, action, reviewTitle, reviewRevisionID, talkRevisionID, gaRevisionID, error ) {
		let textToAppend = '\n* ';
		if ( error ) {
			textToAppend += `<span style="color: red; font-weight: bold;">ERROR:</span> ${ error }. `;
		}

		let verb = '';
		switch ( action ) {
			case 'pass':
				verb = 'passed';
				break;
			case 'fail':
				verb = 'failed';
				break;
			case 'placeOnHold':
				verb = 'placed on hold';
				break;
			case 'askSecondOpinion':
				verb = 'asked second opinion regarding';
				break;
			case 'answerSecondOpinion':
				verb = 'answered second opinion regarding';
				break;
		}
		textToAppend += `[[User:${ username }|${ username }]] ${ verb } [[${ reviewTitle }]] at ~~~~~. `;

		if ( reviewRevisionID ) {
			textToAppend += `[[Special:Diff/${ reviewRevisionID }|[Atop]]]`;
		}
		if ( talkRevisionID ) {
			textToAppend += `[[Special:Diff/${ talkRevisionID }|[Talk]]]`;
		}
		if ( gaRevisionID ) {
			textToAppend += `[[Special:Diff/${ gaRevisionID }|[List]]]`;
		}

		return textToAppend;
	}

	getWikicodeToInsert( gaTitle, gaDisplayTitle ) {
		if ( gaDisplayTitle === gaTitle ) { // use a non-piped wikilink, when possible
			return `[[${ gaTitle }]]\n`;
		} else if ( gaDisplayTitle === `''${ gaTitle }''` ) { // put italics on the outside, when possible
			return `''[[${ gaTitle }]]''\n`;
		} else if ( gaDisplayTitle === `"${ gaTitle }"` ) { // put double quotes on the outside, when possible
			return `"[[${ gaTitle }]]"\n`;
		} else {
			return `[[${ gaTitle }|${ gaDisplayTitle }]]\n`;
		}
	}

	placeATOP( wikicode, result, color ) {
		let colorCode = '';
		switch ( color ) {
			case 'green':
				colorCode = 'g';
				break;
			case 'red':
				colorCode = 'r';
				break;
		}

		// place top piece after first H2, if it exists
		const prependText =
`{{atop${ colorCode }
| status = 
| result = ${ result }
}}`;
		const hasH2 = wikicode.match( /^==[^=]+==$/m );
		if ( hasH2 ) {
			wikicode = wikicode.replace( /^(.*?==[^=]+==\n)(.*)$/s, '$1' + prependText + '\n$2' );
		} else {
			wikicode = prependText + '\n' + wikicode;
		}

		// place bottom piece at end
		const appendText = '{{abot}}';
		wikicode = wikicode.trim();
		wikicode += `\n${ appendText }\n`;

		return wikicode;
	}

	getTopicFromGANomineeTemplate( talkWikicode ) {
		let topic = this.getTemplateParameter( talkWikicode, 'GA nominee', 'topic' );
		if ( !topic ) {
			topic = this.getTemplateParameter( talkWikicode, 'GA nominee', 'subtopic' );
		}
		return topic;
	}

	getTemplateParameter( wikicode, templateName, parameterName ) {
		templateName = this.regExEscape( templateName );
		parameterName = this.regExEscape( parameterName );
		const regex = new RegExp( `\\{\\{${ templateName }[^\\}]+\\|\\s*${ parameterName }\\s*=\\s*([^\\}\\|]+)\\s*[^\\}]*\\}\\}`, 'i' );
		const parameterValue = wikicode.match( regex );
		if ( Array.isArray( parameterValue ) && parameterValue[ 1 ] !== undefined ) {
			return parameterValue[ 1 ].trim();
		} else {
			return null;
		}
	}

	/**
	 * CC BY-SA 4.0, coolaj86, https://stackoverflow.com/a/6969486/3480193
	 */
	regExEscape( string ) {
		return string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); // $& means the whole matched string
	}

	deleteGANomineeTemplate( talkWikicode ) {
		return talkWikicode.replace( /\{\{GA nominee[^}]+\}\}\n?/i, '' );
	}

	addGATemplate( talkWikicode, topic, gaPageNumber, oldid ) {
		const codeToAdd = `{{GA|~~~~~|topic=${ topic }|page=${ gaPageNumber }|oldid=${ oldid }}}\n`;
		return this.addTemplateInCorrectMOSTalkOrderPosition( talkWikicode, codeToAdd );
	}

	addFailedGATemplate( talkWikicode, topic, gaPageNumber, oldid ) {
		const codeToAdd = `{{FailedGA|~~~~~|topic=${ topic }|page=${ gaPageNumber }|oldid=${ oldid }}}\n`;
		return this.addTemplateInCorrectMOSTalkOrderPosition( talkWikicode, codeToAdd );
	}

	addTemplateInCorrectMOSTalkOrderPosition( talkWikicode, codeToAdd ) {
		const templateName = this.getFirstTemplateNameFromWikicode( codeToAdd );
		let templatesThatGoBefore;
		switch ( templateName ) {
			case 'FailedGA':
			case 'GA':
				templatesThatGoBefore = [ 'GA nominee', 'Featured article candidates', 'Peer review', 'Skip to talk', 'Talk header', 'Talkheader', 'Talk page header', 'Talkpage', 'Ds/talk notice', 'Gs/talk notice', 'BLP others', 'Calm', 'Censor', 'Controversial', 'Not a forum', 'FAQ', 'Round in circles', 'American English', 'British English' ]; // [[MOS:TALKORDER]]
				break;
			default:
				throw new Error( 'addTemplateInCorrectMOSTalkOrderPosition: Supplied template is not in dictionary. Unsure where to place it.' );
		}
		return this.addWikicodeAfterTemplates( talkWikicode, templatesThatGoBefore, codeToAdd );
	}

	getFirstTemplateNameFromWikicode( wikicode ) {
		const match = wikicode.match( /(?<=\{\{)[^|}]+/ );
		if ( !match ) {
			throw new Error( 'getFirstTemplateNameFromWikicode: No template found in Wikicode.' );
		}
		return match[ 0 ];
	}

	/**
	 * Search algorithm looks for \n after the searched templates. If not present, it will not match.
	 * @param {string} wikicode
	 * @param {string[]} templates
	 * @param {string} codeToAdd
	 */
	addWikicodeAfterTemplates( wikicode, templates, codeToAdd ) {
		/* Started to write a lexer that would solve the edge case of putting the {{GA}} template too low when the [[MOS:TALKORDER]] is incorrect. It's a lot of work though. Pausing for now.

		// Note: the MOS:TALKORDER $templates variable is fed to us as a parameter
		let whitespace = ["\t", "\n", " "];
		let lastTemplateNameBuffer = '';
		let currentTemplateNameBuffer = '';
		let templateNestingCount = 0;
		for ( i = 0; i < wikicode.length; i++ ) {
			let toCheck = wikicode.slice(i);
			if ( toCheck.startsWith('{{') {
				templateNestingCount++;
			} else if ( toCheck.startsWith('}}') ) {
				templateNestingCount--;
			}
			// TODO: need to build the templateNameBuffer. need to look for termination characters | or }
		*/

		let insertPosition = 0;
		for ( const template of templates ) {
			// TODO: handle nested templates
			const regex = new RegExp( `{{${ this.regExEscape( template ) }[^\\}]*}}\\n`, 'ig' );
			const endOfTemplatePosition = this.getEndOfStringPositionOfLastMatch( wikicode, regex );
			if ( endOfTemplatePosition > insertPosition ) {
				insertPosition = endOfTemplatePosition;
			}
		}
		return this.insertStringIntoStringAtPosition( wikicode, codeToAdd, insertPosition );
	}

	/**
	 * @param {string} haystack
	 * @param {RegExp} regex /g flag must be set
	 * @return {number} endOfStringPosition Returns zero if not found
	 */
	getEndOfStringPositionOfLastMatch( haystack, regex ) {
		const matches = [ ...haystack.matchAll( regex ) ];
		const hasMatches = matches.length;
		if ( hasMatches ) {
			const lastMatch = matches[ matches.length - 1 ];
			const lastMatchStartPosition = lastMatch.index;
			const lastMatchStringLength = lastMatch[ 0 ].length;
			const lastMatchEndPosition = lastMatchStartPosition + lastMatchStringLength;
			return lastMatchEndPosition;
		}
		return 0;
	}

	changeWikiProjectArticleClassToGA( talkWikicode ) {
		// TODO: need to rewrite this to handle the following test case: {{WikiProject Energy|importance=Mid}}. Should add |rating=GA

		// replace existing |class=
		talkWikicode = talkWikicode.replace( /(\|\s*class\s*=\s*)(a|b|c|start|stub|list|fa|fl)?(?=[}\s|])/gi, '$1GA' );

		// add |class= to {{WikiProject}} templates containing no parameters
		talkWikicode = talkWikicode.replace( /(\{\{WikiProject [^|}]+)(\}\})/gi, '$1|class=GA$2' );

		return talkWikicode;
	}

	/**
	 * Determine next |action= number in {{Article history}} template. This is so we can insert an action.
	 */
	determineNextActionNumber( talkWikicode ) {
		let i = 1;
		while ( true ) {
			const regex = new RegExp( `\\|\\s*action${ i }\\s*=`, 'i' );
			const hasAction = talkWikicode.match( regex );
			if ( !hasAction ) {
				return i;
			}
			i++;
		}
	}

	updateArticleHistory( talkWikicode, topic, nominationPageTitle, listedOrFailed, oldid ) {
		const nextActionNumber = this.determineNextActionNumber( talkWikicode );

		if ( listedOrFailed !== 'listed' && listedOrFailed !== 'failed' ) {
			throw new Error( 'InvalidArgumentException' );
		}

		// always write our own topic. especially importnat for passing, because we want to use what the reviewer picked in the combo box, not what was already in the template.
		talkWikicode = this.firstTemplateDeleteParameter( talkWikicode, 'Article ?history', 'topic' );
		const topicString = `\n|topic = ${ topic }`;

		// https://en.wikipedia.org/wiki/Template:Article_history#How_to_use_in_practice
		const existingStatus = this.firstTemplateGetParameterValue( talkWikicode, 'Artricle history', 'currentstatus' );
		talkWikicode = this.firstTemplateDeleteParameter( talkWikicode, 'Article ?history', 'currentstatus' );
		const currentStatusString = this.getArticleHistoryNewStatus( existingStatus, listedOrFailed );

		let addToArticleHistory =
`|action${ nextActionNumber } = GAN
|action${ nextActionNumber }date = ~~~~~
|action${ nextActionNumber }link = ${ nominationPageTitle }
|action${ nextActionNumber }result = ${ listedOrFailed }
|action${ nextActionNumber }oldid = ${ oldid }`;

		addToArticleHistory += currentStatusString + topicString;

		talkWikicode = this.firstTemplateInsertCode( talkWikicode, 'Article ?history', addToArticleHistory );

		return talkWikicode;
	}

	getArticleHistoryNewStatus( existingStatus, listedOrFailed ) {
		if ( listedOrFailed === 'listed' ) {
			switch ( existingStatus ) {
				case 'FFA':
					return '\n|currentstatus = FFA/GA';
				case 'FFAC':
					return '\n|currentstatus = FFAC/GA';
				default:
					return '\n|currentstatus = GA';
			}
		} else {
			switch ( existingStatus ) {
				case 'FFA':
					return '\n|currentstatus = FFA';
				case 'FFAC':
					return '\n|currentstatus = FFAC';
				case 'DGA':
					return '\n|currentstatus = DGA';
				default:
					return '\n|currentstatus = FGAN';
			}
		}
	}

	firstTemplateInsertCode( wikicode, templateNameRegExNoDelimiters, codeToInsert ) {
		// TODO: handle nested templates
		const regex = new RegExp( `(\\{\\{${ templateNameRegExNoDelimiters }[^\\}]*)(\\}\\})`, 'i' );
		return wikicode.replace( regex, `$1\n${ codeToInsert }\n$2` );
	}

	firstTemplateGetParameterValue( wikicode, template, parameter ) {
		// TODO: rewrite to be more robust. currently using a simple algorithm that is prone to failure
		// new algorithm:
			// find start of template. use regex /i (ignore case)
			// iterate using loops until end of template found
				// handle 
				// handle triple {{{
				// handle nested

		const regex = new RegExp( `\\|\\s*${ parameter }\\s*=\\s*([^\\n\\|\\}]*)\\s*`, '' );
		const result = wikicode.match( regex );
		if ( wikicode.match( regex ) === null ) {
			return null;
		}
		return result[ 1 ];
	}

	/**
	 * @param {RegExp} regex
	 */
	preg_position( regex, haystack ) {
		const matches = [ ...haystack.matchAll( regex ) ];
		const hasMatches = matches.length;
		if ( hasMatches ) {
			return matches[ 0 ].index;
		}
		return false;
	}

	findEndOfTemplate( wikicode, templateStartPosition ) {
		// TODO: handle triple braces, handle  tags
		let nesting = 0;
		let templateEndPosition = -1;
		// +1 to skip the first {{, will throw off our nesting count
		for ( let i = templateStartPosition + 1; i < wikicode.length; i++ ) {
			const nextTwoChars = wikicode.slice( i, i + 2 );
			if ( nextTwoChars === '{{' ) {
				nesting++;
				continue;
			} else if ( nextTwoChars === '}}' ) {
				if ( nesting > 0 ) {
					nesting--;
					continue;
				} else {
					templateEndPosition = i + 2;
					break;
				}
			}
		}
		return templateEndPosition;
	}

	firstTemplateDeleteParameter( wikicode, templateRegEx, parameter ) {
		// templateStartPosition
		const regex = new RegExp( '{{' + templateRegEx, 'gi' );
		const templateStartPosition = this.preg_position( regex, wikicode );

		// templateEndPosition
		const templateEndPosition = this.findEndOfTemplate( wikicode, templateStartPosition );

		// slice
		const firstPiece = wikicode.slice( 0, templateStartPosition );
		let secondPiece = wikicode.slice( templateStartPosition, templateEndPosition );
		const thirdPiece = wikicode.slice( templateEndPosition );

		// replace only inside the slice
		const regex2 = new RegExp( `\\|\\s*${ parameter }\\s*=\\s*([^\\n\\|\\}]*)\\s*`, '' );
		secondPiece = secondPiece.replace( regex2, '' );

		// glue back together
		wikicode = firstPiece + secondPiece + thirdPiece;

		return wikicode;
	}

	removeFormattingThatInterferesWithSort( str ) {
		return str.replace( /^[^[]*\[\[(?:[^|]+\|)?/, '' ) // delete anything in front of [[, [[, and anything inside the left half of a piped wikilink
			.replace( /\]\][^\]]*$/, '' ) // delete ]], and anything after ]]
			.replace( /"/g, '' ) // delete "
			.replace( /''/g, '' ) // delete '' but not '
			.replace( /^A /gi, '' ) // delete indefinite article "a"
			.replace( /^An /gi, '' ) // delete indefinite article "an"
			.replace( /^The /gi, '' ); // delete definite article "the"
	}

	aSortsLowerThanB( a, b ) {
		// JavaScript appears to use an ASCII sort. See https://en.wikipedia.org/wiki/ASCII#Printable_characters

		// make sure "A" and "a" sort the same. prevents a bug
		a = a.toLowerCase();
		b = b.toLowerCase();

		a = this.removeDiacritics( a );
		b = this.removeDiacritics( b );

		const arr1 = [ a, b ];
		const arr2 = [ a, b ];

		// Sort numerically, not lexographically.
		// Fixes a bug where the sort is 79, 8, 80 instead of 8, 79, 80
		// Jon Wyatt, CC BY-SA 4.0, https://stackoverflow.com/a/44197285/3480193
		const sortNumerically = ( a, b ) => a.localeCompare( b, 'en', { numeric: true } );

		return JSON.stringify( arr1.sort( sortNumerically ) ) === JSON.stringify( arr2 );
	}

	/**
	 * Jeroen Ooms, CC BY-SA 3.0, https://stackoverflow.com/a/18123985/3480193
	 */
	removeDiacritics( str ) {
		const defaultDiacriticsRemovalMap = [
			{ base: 'A', letters: /[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g },
			{ base: 'AA', letters: /[\uA732]/g },
			{ base: 'AE', letters: /[\u00C6\u01FC\u01E2]/g },
			{ base: 'AO', letters: /[\uA734]/g },
			{ base: 'AU', letters: /[\uA736]/g },
			{ base: 'AV', letters: /[\uA738\uA73A]/g },
			{ base: 'AY', letters: /[\uA73C]/g },
			{ base: 'B', letters: /[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g },
			{ base: 'C', letters: /[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g },
			{ base: 'D', letters: /[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g },
			{ base: 'DZ', letters: /[\u01F1\u01C4]/g },
			{ base: 'Dz', letters: /[\u01F2\u01C5]/g },
			{ base: 'E', letters: /[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g },
			{ base: 'F', letters: /[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g },
			{ base: 'G', letters: /[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g },
			{ base: 'H', letters: /[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g },
			{ base: 'I', letters: /[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g },
			{ base: 'J', letters: /[\u004A\u24BF\uFF2A\u0134\u0248]/g },
			{ base: 'K', letters: /[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g },
			{ base: 'L', letters: /[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g },
			{ base: 'LJ', letters: /[\u01C7]/g },
			{ base: 'Lj', letters: /[\u01C8]/g },
			{ base: 'M', letters: /[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g },
			{ base: 'N', letters: /[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g },
			{ base: 'NJ', letters: /[\u01CA]/g },
			{ base: 'Nj', letters: /[\u01CB]/g },
			{ base: 'O', letters: /[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g },
			{ base: 'OI', letters: /[\u01A2]/g },
			{ base: 'OO', letters: /[\uA74E]/g },
			{ base: 'OU', letters: /[\u0222]/g },
			{ base: 'P', letters: /[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g },
			{ base: 'Q', letters: /[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g },
			{ base: 'R', letters: /[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g },
			{ base: 'S', letters: /[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g },
			{ base: 'T', letters: /[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g },
			{ base: 'TZ', letters: /[\uA728]/g },
			{ base: 'U', letters: /[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g },
			{ base: 'V', letters: /[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g },
			{ base: 'VY', letters: /[\uA760]/g },
			{ base: 'W', letters: /[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g },
			{ base: 'X', letters: /[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g },
			{ base: 'Y', letters: /[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g },
			{ base: 'Z', letters: /[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g },
			{ base: 'a', letters: /[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g },
			{ base: 'aa', letters: /[\uA733]/g },
			{ base: 'ae', letters: /[\u00E6\u01FD\u01E3]/g },
			{ base: 'ao', letters: /[\uA735]/g },
			{ base: 'au', letters: /[\uA737]/g },
			{ base: 'av', letters: /[\uA739\uA73B]/g },
			{ base: 'ay', letters: /[\uA73D]/g },
			{ base: 'b', letters: /[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g },
			{ base: 'c', letters: /[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g },
			{ base: 'd', letters: /[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g },
			{ base: 'dz', letters: /[\u01F3\u01C6]/g },
			{ base: 'e', letters: /[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g },
			{ base: 'f', letters: /[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g },
			{ base: 'g', letters: /[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g },
			{ base: 'h', letters: /[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g },
			{ base: 'hv', letters: /[\u0195]/g },
			{ base: 'i', letters: /[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g },
			{ base: 'j', letters: /[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g },
			{ base: 'k', letters: /[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g },
			{ base: 'l', letters: /[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g },
			{ base: 'lj', letters: /[\u01C9]/g },
			{ base: 'm', letters: /[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g },
			{ base: 'n', letters: /[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g },
			{ base: 'nj', letters: /[\u01CC]/g },
			{ base: 'o', letters: /[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g },
			{ base: 'oi', letters: /[\u01A3]/g },
			{ base: 'ou', letters: /[\u0223]/g },
			{ base: 'oo', letters: /[\uA74F]/g },
			{ base: 'p', letters: /[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g },
			{ base: 'q', letters: /[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g },
			{ base: 'r', letters: /[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g },
			{ base: 's', letters: /[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g },
			{ base: 't', letters: /[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g },
			{ base: 'tz', letters: /[\uA729]/g },
			{ base: 'u', letters: /[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g },
			{ base: 'v', letters: /[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g },
			{ base: 'vy', letters: /[\uA761]/g },
			{ base: 'w', letters: /[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g },
			{ base: 'x', letters: /[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g },
			{ base: 'y', letters: /[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g },
			{ base: 'z', letters: /[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g }
		];

		for ( let i = 0; i < defaultDiacriticsRemovalMap.length; i++ ) {
			str = str.replace( defaultDiacriticsRemovalMap[ i ].letters, defaultDiacriticsRemovalMap[ i ].base );
		}

		return str;
	}

	getGASubpageHeadingPosition( shortenedVersionInComboBox, wikicode ) {
		// split the heading into equalsSignOnOneSide + needle + equalsSignOnOneSide
		const needle = /^={2,5}\s*(.*?)\s*={2,5}$/gm.exec( shortenedVersionInComboBox )[ 1 ];
		const equalsSignsOnOneSide = /^(={2,5})/gm.exec( shortenedVersionInComboBox )[ 1 ];

		// build a wider regex that includes equals, optional spaces next to the equals, optional [[File:]], and optional HTML comments
		const regex = new RegExp( `^${ equalsSignsOnOneSide }\\s*(?:\\[\\[File:[^\\]]*\\]\\]\\s*)?${ this.regExEscape( needle ) }\\s*(?:<!--[^\\-]*-->)?\\s*${ equalsSignsOnOneSide }$`, 'gm' );
		const result = regex.exec( wikicode );

		const resultNotFound = result === null;
		if ( resultNotFound ) {
			throw new Error( `WP:GA subpage heading insert location not found. GANReviewHTMLGenerator.js may need updating. Please add this article to the correct WP:GA subpage manually. Problematic heading: ${ shortenedVersionInComboBox }` );
		} else {
			const headingPosition = result.index;
			return headingPosition;
		}
	}

	findFirstStringAfterPosition( needle, haystack, position ) {
		const len = haystack.length;
		for ( let i = position; i < len; i++ ) {
			const buffer = haystack.slice( i, len );
			if ( buffer.startsWith( needle ) ) {
				return i;
			}
		}
		return -1;
	}

	/**
	 * CC BY-SA 4.0, jAndy, https://stackoverflow.com/a/4364902/3480193
	 */
	insertStringIntoStringAtPosition( bigString, insertString, position ) {
		return [
			bigString.slice( 0, position ),
			insertString,
			bigString.slice( position )
		].join( '' );
	}

	hasArticleHistoryTemplate( wikicode ) {
		return Boolean( wikicode.match( /\{\{Article ?history/i ) );
	}
}


// === modules/GARCloserController.js ======================================================


class GARCloserController {
	/**
	 * @param {jQuery} $ jQuery
	 * @param {mw} mw mediawiki, https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw
	 * @param {Location} location https://developer.mozilla.org/en-US/docs/Web/API/Window/location
	 * @param {GARCloserWikicodeGenerator} wg
	 * @param {GARCloserHTMLGenerator} hg
	 */
	async execute( $, mw, location, wg, hg ) {
		this.$ = $;
		this.mw = mw;
		this.location = location;
		this.wg = wg;
		this.hg = hg;

		this.scriptLogTitle = 'User:Novem Linguae/Scripts/GANReviewTool/GARLog';
		this.editSummarySuffix = ' ([[User:Novem Linguae/Scripts/GANReviewTool|GANReviewTool]])';

		this.garPageTitle = this.mw.config.get( 'wgPageName' ); // includes namespace, underscores instead of spaces
		this.garPageTitle = this.garPageTitle.replace( /_/g, ' ' ); // underscores to spaces. prevents some bugs later

		if ( !this.shouldRunOnThisPageQuickChecks() ) {
			return;
		}

		this.parentArticle = await this.confirmGARAndGetArticleName();
		if ( !this.parentArticle ) {
			return;
		}
		this.talkPageTitle = `Talk:${ this.parentArticle }`;

		const hasGARLinkTemplate = await this.hasGARLinkTemplate( this.talkPageTitle );
		const hasATOP = await this.hasATOP( this.garPageTitle );
		if ( !hasGARLinkTemplate || hasATOP ) {
			return;
		}

		// place HTML on page
		this.$( '#mw-content-text' ).prepend( hg.getHTML() );

		this.$( '#GARCloser-Keep' ).on( 'click', async () => {
			await this.clickKeep();
		} );

		this.$( '#GARCloser-Delist' ).on( 'click', async () => {
			await this.clickDelist();
		} );
	}

	async clickKeep() {
		// TODO: {{subst:GAR/result|result=outcome}} ~~~~ ? Ask Femke. May need to check if user already did it. Would do for both keep and delist.

		try {
			this.editSummary = `close GAR [[${ this.garPageTitle }]] as keep` + this.editSummarySuffix;
			this.deactivateBothButtons();
			this.message = this.$( '#GARCloser-Message' ).val();
			await this.processKeepForGARPage();
			await this.processKeepForTalkPage();
			if ( this.isCommunityAssessment() ) {
				await this.makeCommunityAssessmentLogEntry();
				await this.makeSureCategoryPageHasWikitext();
			}
			await this.makeScriptLogEntry( 'keep' );
			this.pushStatus( 'Done! Reloading...' );
			location.reload();
		} catch ( err ) {
			this.error = err;
			console.error( err );
			// this.editSummary += ' cc [[User:Novem Linguae]]';
			await this.makeScriptLogEntry( 'keep' );
			this.pushStatus( `<span class="GARCloserTool-ErrorNotice">An error occurred :( Details: ${ this.error }</span>` );
		}
	}

	async clickDelist() {
		try {
			this.editSummary = `close GAR [[${ this.garPageTitle }]] as delist` + this.editSummarySuffix;
			if ( !this.apiMode ) {
				this.deactivateBothButtons();
				this.message = this.$( '#GARCloser-Message' ).val();
			}
			await this.processDelistForGARPage();
			await this.processDelistForTalkPage();
			await this.processDelistForArticle();
			await this.processDelistForGAList();
			if ( this.isCommunityAssessment() ) {
				await this.makeCommunityAssessmentLogEntry();
				await this.makeSureCategoryPageHasWikitext();
			}
			await this.makeScriptLogEntry( 'delist' );
			if ( !this.apiMode ) {
				this.pushStatus( 'Done! Reloading...' );
				location.reload();
			}
		} catch ( err ) {
			this.error = err;
			console.error( err );
			this.editSummary += ' cc [[User:Novem Linguae]]';
			await this.makeScriptLogEntry( 'delist' );
			this.pushStatus( `<span class="GARCloserTool-ErrorNotice">An error occurred :( Details: ${ this.error }</span>` );
		}

		if ( this.apiMode && this.error ) {
			throw new Error( this.error );
		}
	}

	/**
	 * Used by MassGARController. Does the same thing as this.clickDelist(), but with JQuery calls fixed to target MassGARController, no refresh of the page at the end of the task, 10 second edit delay for API etiquette reasons, and re-throwing any caught errors.
	 */
	async delistAPI( reassessmentPageTitle, editSummarySuffix, editThrottleInSeconds, message, $, mw, wg ) {
		this.apiMode = true;
		this.editThrottleInSeconds = editThrottleInSeconds;
		this.editSummarySuffix = ` - ${ editSummarySuffix }`;
		this.garPageTitle = reassessmentPageTitle;
		this.message = message;
		this.$ = $;
		this.mw = mw;
		this.wg = wg;

		this.parentArticle = this.getIndividualReassessmentParentArticle( this.garPageTitle );
		this.talkPageTitle = `Talk:${ this.parentArticle }`;
		this.scriptLogTitle = 'User:Novem Linguae/Scripts/GANReviewTool/GARLog';

		await this.clickDelist();
	}

	async getRevisionIDOfNewestRevision( pageTitle ) {
		const api = new this.mw.Api();
		const params = {
			action: 'query',
			format: 'json',
			prop: 'revisions',
			titles: pageTitle,
			formatversion: '2',
			rvlimit: '1',
			rvdir: 'older'
		};
		const result = await api.post( params );
		if ( result.query.pages[ 0 ].missing ) {
			throw new Error( 'getRevisionIDOfNewestRevision: Page appears to have zero revisions' );
		}
		const revisionID = result.query.pages[ 0 ].revisions[ 0 ].revid;
		return revisionID;
	}

	async hasGARLinkTemplate( title ) {
		const wikicode = await this.getWikicode( title );
		return Boolean( wikicode.match( /\{\{GAR\/link/i ) );
	}

	async hasATOP( title ) {
		const wikicode = await this.getWikicode( title );
		return Boolean( wikicode.match( /\{\{Atop/i ) );
		// TODO: don't match a small ATOP, must be ATOP of entire talk page
	}

	deactivateBothButtons() {
		this.$( '#GARCloser-Keep' ).prop( 'disabled', true );
		this.$( '#GARCloser-Delist' ).prop( 'disabled', true );
	}

	async processKeepForGARPage() {
		this.pushStatus( 'Place {{atop}} on GAR page. Replace {{GAR/current}} if present.' );
		let wikicode = await this.getWikicode( this.garPageTitle );
		wikicode = this.wg.processKeepForGARPage( wikicode, this.message, this.isCommunityAssessment() );
		this.garPageRevisionID = await this.makeEdit( this.garPageTitle, this.editSummary, wikicode );
		if ( this.garPageRevisionID === undefined ) {
			throw new Error( 'Generated wikicode and page wikicode were identical, resulting in a null edit.' );
		}
	}

	async processDelistForGARPage() {
		this.pushStatus( 'Place {{atop}} on GAR page' );
		let wikicode = await this.getWikicode( this.garPageTitle );
		wikicode = this.wg.processDelistForGARPage( wikicode, this.message, this.isCommunityAssessment() );
		this.garPageRevisionID = await this.makeEdit( this.garPageTitle, this.editSummary, wikicode );
		if ( this.garPageRevisionID === undefined ) {
			throw new Error( 'Generated wikicode and page wikicode were identical, resulting in a null edit.' );
		}
	}

	async processKeepForTalkPage() {
		this.pushStatus( 'Remove {{GAR/link}} from talk page, and update {{Article history}}' );
		let wikicode = await this.getWikicode( this.talkPageTitle );
		const oldid = await this.getRevisionIDOfNewestRevision( this.parentArticle );
		wikicode = this.wg.processKeepForTalkPage( wikicode, this.garPageTitle, this.talkPageTitle, oldid );
		this.talkRevisionID = await this.makeEdit( this.talkPageTitle, this.editSummary, wikicode );
		if ( this.talkRevisionID === undefined ) {
			throw new Error( 'Generated wikicode and page wikicode were identical, resulting in a null edit.' );
		}
	}

	isCommunityAssessment() {
		if ( this.garPageTitle.startsWith( 'Wikipedia:Good article reassessment/' ) ) {
			return true;
		}
		return false;
	}

	async makeCommunityAssessmentLogEntry() {
		this.pushStatus( 'Add entry to community assessment log' );

		// figure out newest GAR community assessment log (the "archive")
		this.archiveTitle = await this.getHighestNumberedPage( 'Wikipedia:Good article reassessment/Archive ' );
		// TODO: handle no existing log pages at all

		// count # of entries in newest GAR community assessment log (the "archive")
		let archiveOldWikicode = await this.getWikicode( this.archiveTitle );
		const garTemplateCount = this.countGARTemplates( archiveOldWikicode );

		// do we need to start a new archive page?
		const maximumNumberOfHeadingsAllowedInArchive = 82;
		let isNewArchive = false;
		if ( garTemplateCount >= maximumNumberOfHeadingsAllowedInArchive ) {
			this.archiveTitle = this.incrementArchiveTitle( this.archiveTitle );
			isNewArchive = true;
			archiveOldWikicode = '';
			await this.incrementGARArchiveTemplate( this.archiveTitle );
		}

		// add log entry
		const archiveNewWikicode = this.wg.makeCommunityAssessmentLogEntry(
			this.garPageTitle,
			archiveOldWikicode,
			isNewArchive,
			this.archiveTitle
		);
		this.garLogRevisionID = await this.makeEdit( this.archiveTitle, this.editSummary, archiveNewWikicode );
		if ( this.garLogRevisionID === undefined ) {
			throw new Error( 'Generated wikicode and page wikicode were identical, resulting in a null edit.' );
		}
	}

	async makeSureCategoryPageHasWikitext() {
		// use this.archiveTitle to figure out our current GAR archive #
		const archiveNumber = this.archiveTitle.match( /\d+$/ );

		// read the wikicode for Category:GAR/#
		const categoryTitle = `Category:GAR/${ archiveNumber }`;
		const categoryWikicode = await this.getWikicodeAndDoNotThrowError( categoryTitle );

		// if the category has no wikitext, add some boilerplate wikitext, so the category isn't a red link
		if ( !categoryWikicode ) {
			const newWikicode =
`{{Wikipedia category}}

[[Category:Wikipedia good article reassessment]]
`;
			this.categoryRevisionID = await this.makeEdit( categoryTitle, this.editSummary, newWikicode );
		}
	}

	async incrementGARArchiveTemplate( archiveTitle ) {
		this.pushStatus( 'Update count at Template:GARarchive' );
		const wikicode = await this.getWikicode( 'Template:GARarchive' );
		const newTemplateWikicode = this.wg.setGARArchiveTemplate( archiveTitle, wikicode );
		this.garArchiveTemplateRevisionID = await this.makeEdit( 'Template:GARarchive', this.editSummary, newTemplateWikicode );
		if ( this.garArchiveTemplateRevisionID === undefined ) {
			throw new Error( 'Generated wikicode and page wikicode were identical, resulting in a null edit.' );
		}
	}

	/**
	 * Takes a Wikipedia page name with a number on the end, and returns that page name with the number on the end incremented by one. Example: "Wikipedia:Good article reassessment/Archive 67" -> "Wikipedia:Good article reassessment/Archive 68"
	 */
	incrementArchiveTitle( title ) {
		let number = title.match( /\d{1,}$/ );
		number++;
		const titleWithNoNumber = title.replace( /\d{1,}$/, '' );
		return titleWithNoNumber + number.toString();
	}

	/**
	 * Counts number of times "{{Wikipedia:Good article reassessment/" occurs in wikicode.
	 */
	countGARTemplates( wikicode ) {
		return this.countOccurrencesInString( /\{\{Wikipedia:Good article reassessment\//g, wikicode );
	}

	/**
	 * CC BY-SA 4.0, Lorenz Lo Sauer, https://stackoverflow.com/a/10671743/3480193
	 * @param {RegExp} needleRegEx Make sure to set the /g parameter.
	 */
	countOccurrencesInString( needleRegEx, haystack ) {
		return ( haystack.match( needleRegEx ) || [] ).length;
	}

	/**
	 * @param {'keep'|'delist'} keepOrDelist
	 */
	async makeScriptLogEntry( keepOrDelist ) {
		this.pushStatus( 'Add entry to GARCloser debug log' );
		const username = this.mw.config.get( 'wgUserName' );
		const wikicode = this.wg.makeScriptLogEntryToAppend(
			username,
			keepOrDelist,
			this.garPageTitle,
			this.garPageRevisionID,
			this.talkRevisionID,
			this.articleRevisionID,
			this.gaListRevisionID,
			this.garLogRevisionID,
			this.garArchiveTemplateRevisionID,
			this.error,
			this.categoryRevisionID
		);
		await this.appendToPage( this.scriptLogTitle, this.editSummary, wikicode );
	}

	async processDelistForTalkPage() {
		this.pushStatus( 'Remove {{GAR/link}} from talk page, update {{Article history}}, remove |class=GA' );
		let wikicode = await this.getWikicode( this.talkPageTitle );

		// while we have the talk page wikicode, go ahead and figure out the gaListTitle. saves an API query later.
		// this will come back blank if the topic isn't in the dictionary. throw an error later, so that writing the talk page doesn't get interrupted
		this.gaListTitle = this.wg.getGAListTitleFromTalkPageWikicode( wikicode );

		const oldid = await this.getRevisionIDOfNewestRevision( this.parentArticle );
		wikicode = this.wg.processDelistForTalkPage( wikicode, this.garPageTitle, this.talkPageTitle, oldid );
		this.talkRevisionID = await this.makeEdit( this.talkPageTitle, this.editSummary, wikicode );
		if ( this.talkRevisionID === undefined ) {
			throw new Error( 'Generated wikicode and page wikicode were identical, resulting in a null edit.' );
		}
	}

	async processDelistForArticle() {
		this.pushStatus( 'Remove {{Good article}} from article' );
		let wikicode = await this.getWikicode( this.parentArticle );
		wikicode = this.wg.processDelistForArticle( wikicode );
		this.articleRevisionID = await this.makeEdit( this.parentArticle, this.editSummary, wikicode );
		// If we can't remove {{Good article}}, don't throw an error like in the other code paths, just continue. There are cases where this is desirable. For example, maybe the GA got merged and redirected, so the {{Good article}} template itself is no longer present.
	}

	async processDelistForGAList() {
		this.pushStatus( 'Remove article from list of good articles' );

		if ( !this.gaListTitle ) {
			throw new Error( 'Unable to determine WP:GA subpage. Is the |topic= on the article\'s talk page correct?' );
		}

		let wikicode = await this.getWikicode( this.gaListTitle );
		wikicode = this.wg.processDelistForGAList( wikicode, this.parentArticle );
		this.gaListRevisionID = await this.makeEdit( this.gaListTitle, this.editSummary, wikicode );
		// Don't throw an error if we can't find the link to delete. Probably means it was already deleted.
	}

	/**
	 * This also checks if GARCloser should run at all. A falsey result means that the supplied title is not a GAR page.
	 */
	async confirmGARAndGetArticleName() {
		let parentArticle = '';

		// CASE 1: INDIVIDUAL ==================================
		// Example: Talk:Cambodia women's national football team/GA3

		const namespace = this.mw.config.get( 'wgNamespaceNumber' );
		const isTalkNamespace = ( namespace === 1 );

		const isGASubPage = this.isGASubPage( this.garPageTitle );

		// Check this so that we don't accidentally run on GAN subpages, which use the same title formatting
		const garPageWikicode = await this.getWikicode( this.garPageTitle );
		const hasGAReassessmentHeading = garPageWikicode.match( /==GA Reassessment==/i );

		const couldBeIndividualReassessment = isTalkNamespace && isGASubPage && hasGAReassessmentHeading;

		if ( couldBeIndividualReassessment ) {
			parentArticle = this.getIndividualReassessmentParentArticle( this.garPageTitle );
			const parentArticleWikicode = await this.getWikicode( `Talk:${ parentArticle }` );
			if ( parentArticleWikicode.match( /\{\{GAR\/link/i ) ) {
				return parentArticle;
			}
		}

		// CASE 2: COMMUNITY ===================================
		// Example: Wikipedia:Good article reassessment/Cambodia women's national football team/2

		const couldBeCommunityReassessment = this.garPageTitle.startsWith( 'Wikipedia:Good article reassessment/' );
		if ( couldBeCommunityReassessment ) {
			parentArticle = this.getCommunityReassessmentParentArticle( this.garPageTitle );
			const parentArticleWikicode = await this.getWikicode( `Talk:${ parentArticle }` );
			if ( parentArticleWikicode.match( /\{\{GAR\/link/i ) ) {
				return parentArticle;
			}
		}
	}

	getIndividualReassessmentParentArticle( title ) {
		return title.match( /Talk:(.*)\/GA/ )[ 1 ];
	}

	getCommunityReassessmentParentArticle( title ) {
		return title.match( /Wikipedia:Good article reassessment\/(.*)\/\d/ )[ 1 ];
	}

	async getWikicode( title ) {
		const api = new this.mw.Api();
		const params = {
			action: 'parse',
			page: title,
			prop: 'wikitext',
			format: 'json'
		};
		const result = await api.post( params );
		const wikicode = result.parse.wikitext[ '*' ];
		return wikicode;
	}

	async getWikicodeAndDoNotThrowError( title ) {
		try {
			return await this.getWikicode( title );
		} catch ( err ) {
			// Catch error and do nothing
		}

		return '';
	}

	async makeEdit( title, editSummary, wikicode ) {
		if ( this.apiMode ) {
			// API etiquette. 10 second delay between edits.
			await this.delay( this.editThrottleInSeconds );
		}

		const api = new this.mw.Api();
		const params = {
			action: 'edit',
			format: 'json',
			title: title,
			text: wikicode,
			summary: editSummary
		};
		const result = await api.postWithToken( 'csrf', params );
		const revisionID = result.edit.newrevid;
		return revisionID;
	}

	/**
	 * Lets you append without getting the Wikicode first. Saves an API query.
	 * @private
	 */
	async appendToPage( title, editSummary, wikicodeToAppend ) {
		if ( this.apiMode ) {
			// API etiquette. 10 second delay between edits.
			await this.delay( this.editThrottleInSeconds );
		}

		const api = new this.mw.Api();
		const params = {
			action: 'edit',
			format: 'json',
			title: title,
			appendtext: wikicodeToAppend,
			summary: editSummary
		};
		const result = await api.postWithToken( 'csrf', params );
		const revisionID = result.edit.newrevid;
		return revisionID;
	}

	/**
	 * Example: To get the latest archive of "Wikipedia:Good article reassessment/Archive ", use getHighestNumberedPage("Wikipedia:Good article reassessment/Archive "), which will return "Wikipedia:Good article reassessment/Archive 67"
	 * @private
	 */
	async getHighestNumberedPage( prefix ) {
		const t = new this.mw.Title( prefix ); // https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Title
		const prefixNoNamespace = t.getMainText();
		const namespaceNumber = t.getNamespaceId();
		const api = new this.mw.Api();
		const params = {
			action: 'query',
			format: 'json',
			list: 'allpages',
			apprefix: prefixNoNamespace,
			apnamespace: namespaceNumber,
			aplimit: '1',
			apdir: 'descending'
		};
		const result = await api.post( params );
		const title = result.query.allpages[ 0 ].title;
		return title;
	}

	pushStatus( statusToAdd ) {
		if ( this.apiMode ) {
			this.$( '#MassGARTool-Status' ).show();
			this.$( '#MassGARTool-Status > p' ).append( `<br>${ this.parentArticle }: ${ statusToAdd }` );
		} else {
			this.$( '#GARCloserTool-Status' ).show();
			this.$( '#GARCloserTool-Status > p' ).append( `<br>${ statusToAdd }` );
		}
	}

	shouldRunOnThisPageQuickChecks() {
		// don't run when not viewing articles
		const action = this.mw.config.get( 'wgAction' );
		if ( action !== 'view' ) {
			return false;
		}

		// don't run when viewing diffs
		const isDiff = this.mw.config.get( 'wgDiffNewId' );
		if ( isDiff ) {
			return false;
		}

		const isDeletedPage = ( !this.mw.config.get( 'wgCurRevisionId' ) );
		if ( isDeletedPage ) {
			return false;
		}

		// always run in Novem's sandbox
		if ( this.garPageTitle === 'User:Novem_Linguae/sandbox' ) {
			return true;
		}

		return true;
	}

	isGASubPage( title ) {
		return Boolean( title.match( /\/GA\d{1,2}$/ ) );
	}

	async delay( seconds ) {
		const milliseconds = seconds * 1000;
		return new Promise( function ( res ) {
			setTimeout( res, milliseconds );
		} );
	}
}


// === modules/GARCloserHTMLGenerator.js ======================================================

class GARCloserHTMLGenerator {
	getHTML() {
		return `

<style>
	#GARCloserTool {
		border: 1px solid black;
		padding: 1em;
		margin-bottom: 1em;
	}

	#GARCloserTool h2 {
		margin-top: 0;
	}

	#GARCloserTool strong {
		text-decoration: underline;
	}

	#GARCloserTool p {
		margin-top: 1.5em;
		margin-bottom: 1.5em;
		line-height: 1.5em;
	}

	#GARCloserTool-Status {
		display: none;
	}

	.GARCloserTool-ErrorNotice {
		color: red;
		font-weight: bold;
	}

	#GARCloserTool textarea {
		height: auto;
	}
</style>

<div id="GARCloserTool">
	<div id="GARCloserTool-Form">
		<h2>
			GAR Closer Tool
		</h2>

		<p>
			<strong>Closing message</strong><br />
			If you leave this blank, it will default to "Keep" or "Delist"
			<textarea id="GARCloser-Message" rows="4"></textarea>
		</p>

		<p>
			<button id="GARCloser-Keep">Keep</button>
			<button id="GARCloser-Delist">Delist</button>
		</p>
	</div>

	<div id="GARCloserTool-Status">
		<p>
			Processing...
		</p>
	</div>
</div>

`;
	}
}

// === modules/GARCloserWikicodeGenerator.js ======================================================

class GARCloserWikicodeGenerator {
	processKeepForGARPage(garPageWikicode, message, isCommunityAssessment) {
		return this.processGARPage(garPageWikicode, message, isCommunityAssessment, 'Kept.', 'green');
	}

	processKeepForTalkPage(wikicode, garPageTitle, talkPageTitle, oldid) {
		wikicode = this.removeTemplate('GAR/link', wikicode);
		wikicode = this.convertGATemplateToArticleHistoryIfPresent(talkPageTitle, wikicode);
		wikicode = this.updateArticleHistory('keep', wikicode, garPageTitle, oldid);
		return wikicode;
	}

	makeCommunityAssessmentLogEntry(garTitle, wikicode, newArchive, archiveTitle) {
		let output = ``;
		if ( newArchive ) {
			let archiveNumber = this.getArchiveNumber(archiveTitle);
			output +=
`{| class="messagebox"
|-
| [[Image:Filing cabinet icon.svg|50px|Archive]]
| This is an '''[[Wikipedia:How to archive a talk page|archive]]''' of past discussions. Its contents should be preserved in their current form. If you wish to start a new discussion or revive an old one, please do so on the <span class="plainlinks">[{{FULLURL:{{TALKSPACE}}:{{BASEPAGENAME}}}} current talk page]</span>.<!-- Template:Talkarchive -->
|}
{{Template:Process header green
 | title    = Good article reassessment
 | section  = (archive)
 | previous = ([[Wikipedia:Good article reassessment/Archive ${archiveNumber-1}|Page ${archiveNumber-1}]])
 | next     = ([[Wikipedia:Good article reassessment/Archive ${archiveNumber+1}|Page ${archiveNumber+1}]]) 
 | shortcut =
 | notes    =
}}
__TOC__`;
		} else {
			output += wikicode;
		}
		output += `\n{{${garTitle}}}`;
		return output;
	}

	setGARArchiveTemplate(newArchiveTitle, wikicode) {
		let archiveNumber = this.getArchiveNumber(newArchiveTitle);
		return wikicode.replace(/^\d{1,}/, archiveNumber);
	}

	/**
	 * @param {'keep'|'delist'} keepOrDelist
	 * @todo too many params. factor the RevisionIDs into their own class
	 */
	makeScriptLogEntryToAppend(username, keepOrDelist, reviewTitle, garRevisionID, talkRevisionID, articleRevisionID, gaListRevisionID, garLogRevisionID, garArchiveTemplateRevisionID, error, categoryRevisionID) {
		if ( arguments.length !== 11 ) throw new Error('Incorrect # of arguments');

		let textToAppend = `\n* `;

		if ( error ) {
			textToAppend += `<span style="color: red; font-weight: bold;">ERROR:</span> ${error}. `;
		}

		let keepOrDelistPastTense = this.getKeepOrDelistPastTense(keepOrDelist);
		textToAppend += `[[User:${username}|${username}]] ${keepOrDelistPastTense} [[${reviewTitle}]] at ~~~~~. `;

		if ( garRevisionID ) {
			textToAppend += `[[Special:Diff/${garRevisionID}|[Atop]]]`;
		}
		if ( talkRevisionID ) {
			textToAppend += `[[Special:Diff/${talkRevisionID}|[Talk]]]`;
		}
		if ( articleRevisionID ) {
			textToAppend += `[[Special:Diff/${articleRevisionID}|[Article]]]`;
		}
		if ( gaListRevisionID ) {
			textToAppend += `[[Special:Diff/${gaListRevisionID}|[List]]]`;
		}
		if ( garLogRevisionID ) {
			textToAppend += `[[Special:Diff/${garLogRevisionID}|[Log]]]`;
		}
		if ( garArchiveTemplateRevisionID ) {
			textToAppend += `[[Special:Diff/${garArchiveTemplateRevisionID}|[Tmpl]]]`;
		}
		if ( categoryRevisionID ) {
			textToAppend += `[[Special:Diff/${categoryRevisionID}|[Cat]]]`;
		}

		return textToAppend;
	}

	processDelistForGARPage(garPageWikicode, message, isCommunityAssessment) {
		return this.processGARPage(garPageWikicode, message, isCommunityAssessment, 'Delisted.', 'red');
	}

	processDelistForTalkPage(wikicode, garPageTitle, talkPageTitle, oldid) {
		wikicode = this.removeTemplate('GAR/link', wikicode); // "this article is undergoing a GAR"
		wikicode = this.removeTemplate('GAR request', wikicode); // "maybe this article needs a GAR"
		wikicode = this.convertGATemplateToArticleHistoryIfPresent(talkPageTitle, wikicode);
		wikicode = this.updateArticleHistory('delist', wikicode, garPageTitle, oldid);
		wikicode = this.removeGAStatusFromWikiprojectBanners(wikicode);
		return wikicode;
	}

	processDelistForArticle(wikicode) {
		let gaTemplateNames = ['ga icon', 'ga article', 'good article'];
		for ( let templateName of gaTemplateNames ) {
			// handle lots of line breaks: \n\n{{templateName}}\n\n -> \n\n
			let regex = new RegExp('\\n\\n\\{\\{' + templateName + '\\}\\}\\n\\n', 'i');
			wikicode = wikicode.replace(regex, '\n\n');
			
			// handle normal: {{templateName}}\n -> '', {{templateName}} -> ''
			regex = new RegExp('\\{\\{' + templateName + '\\}\\}\\n?', 'i');
			wikicode = wikicode.replace(regex, '');
		}
		return wikicode;
	}

	processDelistForGAList(wikicode, articleToRemove) {
		let regex = new RegExp(`'{0,3}"?\\[\\[${this.regExEscape(articleToRemove)}(?:\\|[^\\]]+)?\\]\\]"?'{0,3}\\n`, 'gi');
		wikicode = wikicode.replace(regex, '');
		return wikicode;
	}

	processGARPage(garPageWikicode, message, isCommunityAssessment, defaultText, atopColor) {
		message = this.setMessageIfEmpty(defaultText, message);
		message = this.addSignatureIfMissing(message);
		let messageForAtop = this.getMessageForAtop(isCommunityAssessment, message);
		let result = this.placeATOP(garPageWikicode, messageForAtop, atopColor);
		if ( isCommunityAssessment ) {
			result = this.replaceGARCurrentWithGARResult(message, result);
		}
		return result;
	}

	/**
	  * Public. Used in GARCloserController.
	  */
	getGAListTitleFromTalkPageWikicode(wikicode) {
		/** Keys should all be lowercase */
		let dictionary = {
			'agriculture': 'Wikipedia:Good articles/Agriculture, food and drink',
			'agriculture, food and drink': 'Wikipedia:Good articles/Agriculture, food and drink',
			'agriculture, food, and drink': 'Wikipedia:Good articles/Agriculture, food and drink',
			'cuisine': 'Wikipedia:Good articles/Agriculture, food and drink',
			'cuisines': 'Wikipedia:Good articles/Agriculture, food and drink',
			'cultivation': 'Wikipedia:Good articles/Agriculture, food and drink',
			'drink': 'Wikipedia:Good articles/Agriculture, food and drink',
			'farming and cultivation': 'Wikipedia:Good articles/Agriculture, food and drink',
			'farming': 'Wikipedia:Good articles/Agriculture, food and drink',
			'food and drink': 'Wikipedia:Good articles/Agriculture, food and drink',
			'food': 'Wikipedia:Good articles/Agriculture, food and drink',

			'art': 'Wikipedia:Good articles/Art and architecture',
			'architecture': 'Wikipedia:Good articles/Art and architecture',
			'art and architecture': 'Wikipedia:Good articles/Art and architecture',

			'engtech': 'Wikipedia:Good articles/Engineering and technology',
			'applied sciences and technology': 'Wikipedia:Good articles/Engineering and technology',
			'applied sciences': 'Wikipedia:Good articles/Engineering and technology',
			'computers': 'Wikipedia:Good articles/Engineering and technology',
			'computing and engineering': 'Wikipedia:Good articles/Engineering and technology',
			'computing': 'Wikipedia:Good articles/Engineering and technology',
			'eng': 'Wikipedia:Good articles/Engineering and technology',
			'engineering': 'Wikipedia:Good articles/Engineering and technology',
			'engineering and technology': 'Wikipedia:Good articles/Engineering and technology',
			'technology': 'Wikipedia:Good articles/Engineering and technology',
			'transport': 'Wikipedia:Good articles/Engineering and technology',

			'geography': 'Wikipedia:Good articles/Geography and places',
			'geography and places': 'Wikipedia:Good articles/Geography and places',
			'places': 'Wikipedia:Good articles/Geography and places',

			'history': 'Wikipedia:Good articles/History',
			'archaeology': 'Wikipedia:Good articles/History',
			'heraldry': 'Wikipedia:Good articles/History',
			'nobility': 'Wikipedia:Good articles/History',
			'royalty': 'Wikipedia:Good articles/History',
			'royalty, nobility and heraldry': 'Wikipedia:Good articles/History',
			'world history': 'Wikipedia:Good articles/History',

			'langlit': 'Wikipedia:Good articles/Language and literature',
			'language and literature': 'Wikipedia:Good articles/Language and literature',
			'languages and linguistics': 'Wikipedia:Good articles/Language and literature',
			'languages and literature': 'Wikipedia:Good articles/Language and literature',
			'languages': 'Wikipedia:Good articles/Language and literature',
			'linguistics': 'Wikipedia:Good articles/Language and literature',
			'lit': 'Wikipedia:Good articles/Language and literature',
			'literature': 'Wikipedia:Good articles/Language and literature',

			'math': 'Wikipedia:Good articles/Mathematics',
			'mathematics and mathematicians': 'Wikipedia:Good articles/Mathematics',
			'mathematics': 'Wikipedia:Good articles/Mathematics',
			'maths': 'Wikipedia:Good articles/Mathematics',

			'drama': 'Wikipedia:Good articles/Media and drama',
			'ballet': 'Wikipedia:Good articles/Media and drama',
			'dance': 'Wikipedia:Good articles/Media and drama',
			'film': 'Wikipedia:Good articles/Media and drama',
			'films': 'Wikipedia:Good articles/Media and drama',
			'media and drama': 'Wikipedia:Good articles/Media and drama',
			'media': 'Wikipedia:Good articles/Media and drama',
			'opera': 'Wikipedia:Good articles/Media and drama',
			'television': 'Wikipedia:Good articles/Media and drama',
			'theater': 'Wikipedia:Good articles/Media and drama',
			'theatre': 'Wikipedia:Good articles/Media and drama',
			'theatre, film and drama': 'Wikipedia:Good articles/Media and drama',

			'music': 'Wikipedia:Good articles/Music',
			'albums': 'Wikipedia:Good articles/Music',
			'classical compositions': 'Wikipedia:Good articles/Music',
			'other music articles': 'Wikipedia:Good articles/Music',
			'songs': 'Wikipedia:Good articles/Music',

			'natsci': 'Wikipedia:Good articles/Natural sciences',
			'astronomy': 'Wikipedia:Good articles/Natural sciences',
			'astrophysics': 'Wikipedia:Good articles/Natural sciences',
			'atmospheric science': 'Wikipedia:Good articles/Natural sciences',
			'biology and medicine': 'Wikipedia:Good articles/Natural sciences',
			'biology': 'Wikipedia:Good articles/Natural sciences',
			'chemistry and materials science': 'Wikipedia:Good articles/Natural sciences',
			'chemistry': 'Wikipedia:Good articles/Natural sciences',
			'cosmology': 'Wikipedia:Good articles/Natural sciences',
			'earth science': 'Wikipedia:Good articles/Natural sciences',
			'earth sciences': 'Wikipedia:Good articles/Natural sciences',
			'geology': 'Wikipedia:Good articles/Natural sciences',
			'geophysics': 'Wikipedia:Good articles/Natural sciences',
			'medicine': 'Wikipedia:Good articles/Natural sciences',
			'meteorology and atmospheric sciences': 'Wikipedia:Good articles/Natural sciences',
			'meteorology': 'Wikipedia:Good articles/Natural sciences',
			'mineralogy': 'Wikipedia:Good articles/Natural sciences',
			'natural science': 'Wikipedia:Good articles/Natural sciences',
			'natural sciences': 'Wikipedia:Good articles/Natural sciences',
			'physics and astronomy': 'Wikipedia:Good articles/Natural sciences',
			'physics': 'Wikipedia:Good articles/Natural sciences',

			'philrelig': 'Wikipedia:Good articles/Philosophy and religion',
			'mysticism': 'Wikipedia:Good articles/Philosophy and religion',
			'myth': 'Wikipedia:Good articles/Philosophy and religion',
			'mythology': 'Wikipedia:Good articles/Philosophy and religion',
			'phil': 'Wikipedia:Good articles/Philosophy and religion',
			'philosophy and religion': 'Wikipedia:Good articles/Philosophy and religion',
			'philosophy': 'Wikipedia:Good articles/Philosophy and religion',
			'relig': 'Wikipedia:Good articles/Philosophy and religion',
			'religion': 'Wikipedia:Good articles/Philosophy and religion',
			'religion, mysticism and mythology': 'Wikipedia:Good articles/Philosophy and religion',

			'socsci': 'Wikipedia:Good articles/Social sciences and society',
			'business and economics': 'Wikipedia:Good articles/Social sciences and society',
			'business': 'Wikipedia:Good articles/Social sciences and society',
			'culture and society': 'Wikipedia:Good articles/Social sciences and society',
			'culture': 'Wikipedia:Good articles/Social sciences and society',
			'culture, society and psychology': 'Wikipedia:Good articles/Social sciences and society',
			'economics and business': 'Wikipedia:Good articles/Social sciences and society',
			'economics': 'Wikipedia:Good articles/Social sciences and society',
			'education': 'Wikipedia:Good articles/Social sciences and society',
			'gov': 'Wikipedia:Good articles/Social sciences and society',
			'government': 'Wikipedia:Good articles/Social sciences and society',
			'journalism and media': 'Wikipedia:Good articles/Social sciences and society',
			'journalism': 'Wikipedia:Good articles/Social sciences and society',
			'law': 'Wikipedia:Good articles/Social sciences and society',
			'magazines and print journalism': 'Wikipedia:Good articles/Social sciences and society',
			'media and journalism': 'Wikipedia:Good articles/Social sciences and society',
			'politics and government': 'Wikipedia:Good articles/Social sciences and society',
			'politics': 'Wikipedia:Good articles/Social sciences and society',
			'psychology': 'Wikipedia:Good articles/Social sciences and society',
			'social science': 'Wikipedia:Good articles/Social sciences and society',
			'social sciences and society': 'Wikipedia:Good articles/Social sciences and society',
			'social sciences': 'Wikipedia:Good articles/Social sciences and society',
			'society': 'Wikipedia:Good articles/Social sciences and society',

			'sports': 'Wikipedia:Good articles/Sports and recreation',
			'everyday life': 'Wikipedia:Good articles/Sports and recreation',
			'everydaylife': 'Wikipedia:Good articles/Sports and recreation',
			'games': 'Wikipedia:Good articles/Sports and recreation',
			'recreation': 'Wikipedia:Good articles/Sports and recreation',
			'sport and recreation': 'Wikipedia:Good articles/Sports and recreation',
			'sport': 'Wikipedia:Good articles/Sports and recreation',
			'sports and recreation': 'Wikipedia:Good articles/Sports and recreation',

			'video games': 'Wikipedia:Good articles/Video games',
			'video and computer games': 'Wikipedia:Good articles/Video games',

			'war': 'Wikipedia:Good articles/Warfare',
			'aircraft': 'Wikipedia:Good articles/Warfare',
			'battles and exercises': 'Wikipedia:Good articles/Warfare',
			'battles': 'Wikipedia:Good articles/Warfare',
			'decorations and memorials': 'Wikipedia:Good articles/Warfare',
			'military': 'Wikipedia:Good articles/Warfare',
			'military people': 'Wikipedia:Good articles/Warfare',
			'units': 'Wikipedia:Good articles/Warfare',
			'war and military': 'Wikipedia:Good articles/Warfare',
			'warfare': 'Wikipedia:Good articles/Warfare',
			'warships': 'Wikipedia:Good articles/Warfare',
			'weapons and buildings': 'Wikipedia:Good articles/Warfare',
			'weapons': 'Wikipedia:Good articles/Warfare',
		};
		let topic = wikicode.match(/(?:\{\{Article ?history|\{\{GA\s*(?=\|)).*?\|\s*(?:sub)?topic\s*=\s*([^\|\}\n]+)/is)[1];
		topic = topic.toLowerCase().trim();
		let gaListTitle = dictionary[topic];
		// throw the error a little later rather than now. that way it doesn't interrupt modifying the article talk page.
		return gaListTitle;
	}

	addSignatureIfMissing(message) {
		if ( ! message.includes('~~~~') ) {
			message += ' ~~~~';
		}
		return message;
	}

	setMessageIfEmpty(defaultText, message) {
		if ( message === '' ) {
			message = defaultText;
		}
		return message;
	}

	getMessageForAtop(isCommunityAssessment, message) {
		let messageForAtop = message;
		if ( isCommunityAssessment ) {
			messageForAtop = '';
		}
		return messageForAtop;
	}

	/**
	 * {{GAR/current}} and {{GAR/result}} are templates used in community reassessment GARs. The first needs to be swapped for the second when closing community reassessment GARs.
	 */
	replaceGARCurrentWithGARResult(message, wikicode) {
		message = message.replace(/ ?~~~~/g, '');
		return wikicode.replace(/\{\{GAR\/current\}\}/i, `{{subst:GAR/result|result=${this.escapeTemplateParameter(message)}}} ~~~~`);
	}

	escapeTemplateParameter(parameter) {
		// TODO: This needs repair. Should only escape the below if they are not inside of a template. Should not escape them at all times. Commenting out for now.
		// parameter = parameter.replace(/\|/g, '{{!}}');
		// parameter = parameter.replace(/=/g, '{{=}}');
		return parameter;
	}

	/**
	 * Takes a Wikipedia page name with a number on the end, and returns that number.
	 */
	getArchiveNumber(title) {
		return parseInt(title.match(/\d{1,}$/));
	}

	placeATOP(wikicode, result, color) {
		let colorCode = '';
		switch ( color ) {
			case 'green':
				colorCode = 'g';
				break;
			case 'red':
				colorCode = 'r';
				break;
		}

		// place top piece after first H2 or H3, if it exists
		let resultText = result ? `\n| result = ${result}\n` : '';
		let prependText =
`{{atop${colorCode}${resultText}}}`;
		let hasH2OrH3 = wikicode.match(/^===?[^=]+===?$/m);
		if ( hasH2OrH3 ) {
			wikicode = wikicode.replace(/^(.*?===?[^=]+===?\n)\n*(.*)$/s, '$1' + prependText + '\n$2');
		} else {
			wikicode = prependText + "\n" + wikicode;
		}

		// place bottom piece at end
		let appendText = `{{abot}}`;
		wikicode = wikicode.trim();
		wikicode += `\n${appendText}\n`;

		return wikicode;
	}

	/**
	 * CC BY-SA 4.0, coolaj86, https://stackoverflow.com/a/6969486/3480193
	 */
	regExEscape(string) {
		return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
	}

	removeTemplate(templateName, wikicode) {
		let regex = new RegExp(`\\{\\{${this.regExEscape(templateName)}[^\\}]*\\}\\}\\n?`, 'i');
		return wikicode.replace(regex, '');
	}

	regexGetFirstMatchString(regex, haystack) {
		let matches = haystack.match(regex);
		if ( matches !== null && matches[1] !== undefined ) {
			return matches[1];
		}
		return null;
	}

	/**
	 * There's a {{GA}} template that some people use instead of {{Article history}}. If this is present, replace it with {{Article history}}.
	 */
	convertGATemplateToArticleHistoryIfPresent(talkPageTitle, wikicode) {
		let hasArticleHistory = Boolean(wikicode.match(/\{\{Article ?history([^\}]*)\}\}/gi));
		let gaTemplateWikicode = this.regexGetFirstMatchString(/(\{\{GA[^\}]*\}\})/i, wikicode);
		if ( ! hasArticleHistory && gaTemplateWikicode ) {
			// delete {{ga}} template
			wikicode = wikicode.replace(/\{\{GA[^\}]*\}\}\n?/i, '');
			wikicode = wikicode.trim();
			
			// parse its parameters
			// example: |21:00, 12 March 2017 (UTC)|topic=Sports and recreation|page=1|oldid=769997774
			let parameters = this.getParametersFromTemplateWikicode(gaTemplateWikicode);
			
			// if no page specified, assume page is 1. so then the good article review link will be parsed as /GA1
			let noPageSpecified = parameters['page'] === undefined;
			if ( noPageSpecified ) {
				parameters['page'] = 1;
			}
			
			let topicString = '';
			if ( parameters['topic'] !== undefined ) {
				topicString = `\n|topic = ${parameters['topic']}`;
			} else if ( parameters['subtopic'] !== undefined ) { // subtopic is an alias only used in {{ga}}, it is not used in {{article history}}
				topicString = `\n|topic = ${parameters['subtopic']}`;
			}

			let oldIDString = '';
			if ( parameters['oldid'] !== undefined ) {
				oldIDString = `\n|action1oldid = ${parameters['oldid']}`;
			}

			// if |1= was used for date instead of |date=
			if ( parameters['date'] === undefined && parameters[1] !== undefined) {
				parameters['date'] = parameters[1];
			}

			// insert {{article history}} template
			let addToTalkPageAboveWikiProjects = 
`{{Article history
|currentstatus = GA${topicString}

|action1 = GAN
|action1date = ${parameters['date']}
|action1link = ${talkPageTitle}/GA${parameters['page']}
|action1result = listed${oldIDString}
}}`;
			wikicode = this.addToTalkPageAboveWikiProjects(wikicode, addToTalkPageAboveWikiProjects);
		}
		return wikicode;
	}

	/**
	 * Adds wikicode right above {{WikiProject X}} or {{WikiProject Banner Shell}} if present, or first ==Header== if present, or at bottom of page. Treat {{Talk:abc/GA1}} as a header.
	 */
	addToTalkPageAboveWikiProjects(talkPageWikicode, wikicodeToAdd) {
		if ( ! talkPageWikicode ) {
			return wikicodeToAdd;
		}
		
		// Find first WikiProject or WikiProject banner shell template
		let wikiProjectLocation = false;
		let dictionary = ['wikiproject', 'wpb', 'wpbs', 'wpbannershell', 'wp banner shell', 'bannershell', 'scope shell', 'project shell', 'multiple wikiprojects', 'football'];
		for ( let value of dictionary ) {
			let location = talkPageWikicode.toUpperCase().indexOf('{{' + value.toUpperCase()); // case insensitive
			if ( location !== -1 ) {
				// if this location is higher up than the previous found location, overwrite it
				if ( wikiProjectLocation === false || wikiProjectLocation > location ) {
					wikiProjectLocation = location;
				}
			}
		}
		
		// Find first heading
		let headingLocation = talkPageWikicode.indexOf('==');
		
		// Find first {{Talk:abc/GA1}} template
		let gaTemplateLocation = this.preg_position(new RegExp(`\\{\\{[^\\}]*\\/GA\\d{1,2}\\}\\}`, 'gis'), talkPageWikicode);
		
		// Set insert location
		let insertPosition;
		if ( wikiProjectLocation !== false ) {
			insertPosition = wikiProjectLocation;
		} else if ( headingLocation !== -1 ) {
			insertPosition = headingLocation;
		} else if ( gaTemplateLocation !== false ) {
			insertPosition = gaTemplateLocation;
		} else {
			insertPosition = talkPageWikicode.length; // insert at end of page
		}
		
		// if there's a {{Talk:abc/GA1}} above a heading, adjust for this
		if (
			headingLocation !== -1 &&
			gaTemplateLocation !== false &&
			gaTemplateLocation < insertPosition
		) {
			insertPosition = gaTemplateLocation;
		}
		
		// If there's excess newlines in front of the insert location, delete the newlines
		let deleteTopPosition = false;
		let deleteBottomPosition = false;
		let pos = insertPosition <= 0 ? 0 : insertPosition - 1;
		let i = 1;
		while ( pos != 0 ) {
			let char = talkPageWikicode.substr(pos, 1);
			if ( char == "\n" ) {
				if ( i != 1 && i != 2 ) { // skip first two \n, those are OK to keep
					// @ts-ignore
					deleteTopPosition = pos;
					if ( i == 3 ) {
						deleteBottomPosition = insertPosition;
					}
				}
				insertPosition = pos; // insert position should back up past all \n's.
				i++;
				pos--;
			} else {
				break;
			}
		}
		if ( deleteTopPosition !== false ) {
			talkPageWikicode = this.deleteMiddleOfString(talkPageWikicode, deleteTopPosition, deleteBottomPosition);
		}
		
		let lengthOfRightHalf = talkPageWikicode.length - insertPosition;
		let leftHalf = talkPageWikicode.substr(0, insertPosition);
		let rightHalf = talkPageWikicode.substr(insertPosition, lengthOfRightHalf);
		
		if ( insertPosition == 0 ) {
			return wikicodeToAdd + "\n" + talkPageWikicode;
		} else {
			return leftHalf + "\n" + wikicodeToAdd + rightHalf;
		}
	}

	/**
	 * @param {RegExp} regex
	 */
	preg_position(regex, haystack) {
		let matches = [...haystack.matchAll(regex)];
		let hasMatches = matches.length;
		if ( hasMatches ) {
			return matches[0]['index'];
		}
		return false;
	}

	deleteMiddleOfString(string, deleteStartPosition, deleteEndPosition) {
		let part1 = string.substr(0, deleteStartPosition);
		let part2 = string.substr(deleteEndPosition);
		let final_str = part1 + part2;
		return final_str;
	}

	/**
	 * @returns {Object} Parameters, with keys being equivalent to the template parameter names. Unnamed parameters will be 1, 2, 3, etc.
	 */
	getParametersFromTemplateWikicode(wikicodeOfSingleTemplate) {
		wikicodeOfSingleTemplate = wikicodeOfSingleTemplate.slice(2, -2); // remove {{ and }}
		// TODO: explode without exploding | inside of inner templates
		let strings = wikicodeOfSingleTemplate.split('|');
		let parameters = {};
		let unnamedParameterCount = 1;
		let i = 0;
		for ( let string of strings ) {
			i++;
			if ( i == 1 ) {
				continue; // skip the template name, this is not a parameter 
			}
			let hasEquals = string.indexOf('=');
			if ( hasEquals === -1 ) {
				parameters[unnamedParameterCount] = string;
				unnamedParameterCount++;
			} else {
				let matches = string.match(/^([^=]*)=(.*)/s); // isolate param name and param value by looking for first equals sign
				let paramName = matches[1].trim().toLowerCase(); 
				let paramValue = matches[2].trim();
				parameters[paramName] = paramValue;
			}
		}
		return parameters;
	}

	/**
	 * @param {'keep'|'delist'} keepOrDelist
	 */
	updateArticleHistory(keepOrDelist, wikicode, garPageTitle, oldid) {
		let nextActionNumber = this.determineNextActionNumber(wikicode);

		if ( keepOrDelist !== 'keep' && keepOrDelist !== 'delist' ) {
			throw new Error('InvalidArgumentException');
		}

		let topic = this.firstTemplateGetParameterValue(wikicode, 'Artricle history', 'topic');
		let topicString = '';
		if ( ! topic ) {
			topicString = `\n|topic = ${topic}`;
		}

		// https://en.wikipedia.org/wiki/Template:Article_history#How_to_use_in_practice
		let existingStatus = this.firstTemplateGetParameterValue(wikicode, 'Artricle history', 'currentstatus');
		wikicode = this.firstTemplateDeleteParameter(wikicode, 'Article history', 'currentstatus');
		let currentStatusString = this.getArticleHistoryNewStatus(existingStatus, keepOrDelist);

		let result = this.getKeepOrDelistPastTense(keepOrDelist);

		let addToArticleHistory = 
`|action${nextActionNumber} = GAR
|action${nextActionNumber}date = ~~~~~
|action${nextActionNumber}link = ${garPageTitle}
|action${nextActionNumber}result = ${result}
|action${nextActionNumber}oldid = ${oldid}`;

		addToArticleHistory += currentStatusString + topicString;

		wikicode = this.firstTemplateInsertCode(wikicode, ['Article history', 'ArticleHistory'], addToArticleHistory);

		return wikicode;
	}

	getKeepOrDelistPastTense(keepOrDelist) {
		switch ( keepOrDelist ) {
			case 'keep':
				return 'kept';
			case 'delist':
				return 'delisted';
		}
	}

	/**
	 * Determine next |action= number in {{Article history}} template. This is so we can insert an action.
	 */
	determineNextActionNumber(wikicode) {
		let i = 1;
		while ( true ) {
			let regex = new RegExp(`\\|\\s*action${i}\\s*=`, 'i');
			let hasAction = wikicode.match(regex);
			if ( ! hasAction ) {
				return i;
			}
			i++;
		}
	}

	firstTemplateGetParameterValue(wikicode, template, parameter) {
		// TODO: rewrite to be more robust. currently using a simple algorithm that is prone to failure
		// new algorithm:
			// find start of template. use regex /i (ignore case)
			// iterate using loops until end of template found
				// handle 
				// handle triple {{{
				// handle nested
		
		let regex = new RegExp(`\\|\\s*${parameter}\\s*=\\s*([^\\n\\|\\}]*)\\s*`, '');
		let result = wikicode.match(regex);
		if ( wikicode.match(regex) === null ) return null;
		return result[1];
	}

	getArticleHistoryNewStatus(existingStatus, keepOrDelist) {
		if ( keepOrDelist === 'keep' ) {
			return `\n|currentstatus = ${existingStatus}`;
		} else {
			return '\n|currentstatus = DGA';
		}
	}

	/**
	 * @param {Array} templateNameArrayCaseInsensitive
	 */
	firstTemplateInsertCode(wikicode, templateNameArrayCaseInsensitive, codeToInsert) {
		for ( let templateName of templateNameArrayCaseInsensitive ) {
			let strPosOfEndOfFirstTemplate = this.getStrPosOfEndOfFirstTemplateFound(wikicode, templateName);
			if ( strPosOfEndOfFirstTemplate !== null ) {
				let insertPosition = strPosOfEndOfFirstTemplate - 2; // 2 characters from the end, right before }}
				let result = this.insertStringIntoStringAtPosition(wikicode, `\n${codeToInsert}\n`, insertPosition);
				return result;
			}
		}
	}

	/**
	 * CC BY-SA 4.0, jAndy, https://stackoverflow.com/a/4364902/3480193
	 */
	insertStringIntoStringAtPosition(bigString, insertString, position) {
		return [
			bigString.slice(0, position),
			insertString,
			bigString.slice(position)
		].join('');
	}

	/**
	 * Grabs string position of the END of first {{template}} contained in wikicode. Case insensitive. Returns null if no template found. Handles nested templates.
	 * @returns {number|null}
	 */
	getStrPosOfEndOfFirstTemplateFound(wikicode, templateName) {
		let starting_position = wikicode.toLowerCase().indexOf("{{" + templateName.toLowerCase());
		if ( starting_position === -1 ) return null;
		let counter = 0;
		let length = wikicode.length;
		for ( let i = starting_position + 2; i < length; i++ ) {
			let next_two = wikicode.substr(i, 2);
			if ( next_two == "{{" ) {
				counter++;
				continue;
			} else if ( next_two == "}}" ) {
				if ( counter == 0 ) {
					return i + 2; // +2 to account for next_two being }} (2 characters)
				} else {
					counter--;
					continue;
				}
			}
		}
		return null;
	}

	removeGAStatusFromWikiprojectBanners(wikicode) {
		return wikicode.replace(/(\|\s*class\s*=\s*)([^\}\|\s]*)/gi, '$1');
	}

	firstTemplateDeleteParameter(wikicode, template, parameter) {
		// TODO: rewrite to be more robust. currently using a simple algorithm that is prone to failure
		let regex = new RegExp(`\\|\\s*${parameter}\\s*=\\s*([^\\n\\|\\}]*)\\s*`, '');
		return wikicode.replace(regex, '');
	}
}

// === modules/MassGARController.js ======================================================


/**
  * Run the MassGAR tool by visiting https://en.wikipedia.org/wiki/User:Novem_Linguae/Scripts/GANReviewTool/MassGAR.
  */
class MassGARController {
	/**
	 * @param {jQuery} $ jQuery
	 * @param {mw} mw mediawiki, https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw
	 * @param {MassGARWikicodeGenerator} mgwg
	 * @param {GARCloserController} gcc
	 * @param {GARCloserWikicodeGenerator} gcwg
	 */
	async execute($, mw, mgwg, gcc, gcwg) {
		// TODO: delete any of these that are unused
		this.$ = $; // used
		this.mw = mw; // used
		this.mgwg = mgwg; // used
		this.gcc = gcc;
		this.gcwg = gcwg;

		// API etiquette. 10 second delay between edits.
		this.editThrottleInSeconds = 10;

		if ( ! this.isCorrectPage() ) {
			return;
		}

		if ( ! this.isAuthorizedUser() ) {
			mw.notify('Sorry. You are not currently authorized to run mass GARs.');
			return;
		}

		this.showHTMLForm();

		this.$(`#MassGARTool-Run`).on('click', async () => {
			try {
				await this.clickRun();
			} catch (err) {
				this.error = err;
				console.error(err);
				this.pushStatus(`<span class="MassGARTool-ErrorNotice">An error occurred :( Details: ${this.error}</span>`);
			}
		});

	}

	async clickRun() {
		this.pushStatus(`<br>Run button was clicked. Starting new run.`);

		let listOfMainArticleTitles = this.$('#MassGARTool-ListOfGARs').val().trim().split('\n');
		this.reassessmentPageWikicode = this.$('#MassGARTool-IndividualReassessmentPageWikicode').val();
		this.editSummary = this.$('#MassGARTool-EditSummary').val();

		for ( let mainArticleTitle of listOfMainArticleTitles ) {
			this.mainArticleTitle = mainArticleTitle;

			this.pushStatus(`${this.mainArticleTitle}: Started this article.`);

			// getting these here to minimize API queries
			this.mainArticleWikicode = await this.getWikicode(this.mainArticleTitle);
			this.talkPageTitle = new this.mw.Title(this.mainArticleTitle).getTalkPage().getPrefixedText();
			this.talkPageWikicode = await this.getWikicode(this.talkPageTitle);

			this.verifyGoodArticleStatus();
			this.verifyNoOpenGAR();
			await this.placeGARTemplateOnTalkPage();
			await this.createIndividualReassessmentPage();

			await this.gcc.delistAPI(this.reassessmentPageTitle, this.editSummary, this.editThrottleInSeconds, '', this.$, this.mw, this.gcwg);

			this.pushStatus(`${this.mainArticleTitle}: Completed this article.`);
		}

		this.pushStatus(`Run complete.`);
	}

	verifyGoodArticleStatus() {
		this.pushStatus(`${this.mainArticleTitle}: Checking to make sure that it's a good article.`);

		if ( ! this.mgwg.hasGoodArticleTemplate(this.mainArticleWikicode) ) {
			throw new Error(`${this.mainArticleTitle}: doesn't appear to be a good article. The main article page is missing a GA topicon.`);
		}

		if ( ! this.mgwg.talkPageIndicatesGA(this.talkPageWikicode) ) {
			throw new Error(`${this.mainArticleTitle}: doesn't appear to be a good article. The article talk page does not indicate that this is a good article.`);
		}
	}

	verifyNoOpenGAR() {
		this.pushStatus(`${this.mainArticleTitle}: Checking to make sure that there isn't an open GAR.`);

		if ( this.mgwg.hasOpenGAR(this.talkPageWikicode) ) {
			throw new Error(`${this.mainArticleTitle}: someone appears to have already opened a GAR. The talk page contains the template {{GAR/link}}.`);
		}
	}

	async placeGARTemplateOnTalkPage() {
		this.pushStatus(`${this.mainArticleTitle}: Placing {{subst:GAR}} template on talk page, which will transform into {{GAR/link}}.`);

		let textToPrepend = `{{subst:GAR}}\n`;
		await this.prependEdit(this.talkPageTitle, this.editSummary, textToPrepend);
	}

	/**
	  * This does not notify the nominator, notify the creator, or transclude the reassessment to the talk page. This only creates the individual reassessment page.
	  */
	async createIndividualReassessmentPage() {
		this.pushStatus(`${this.mainArticleTitle}: Creating an individual assessment page.`);

		let searchPrefixNoNamespace = this.mainArticleTitle + '/GA';
		let listOfPages = await this.getAllSubpagesStartingWith(searchPrefixNoNamespace);
		this.reassessmentPageTitle = await this.getNextUnusedGASubpageTitle(listOfPages, this.mainArticleTitle);
		this.pushStatus(`${this.mainArticleTitle}: Decided to name the subpage ${this.reassessmentPageTitle}.`);

		await this.makeEdit(this.reassessmentPageTitle, this.editSummary, this.reassessmentPageWikicode);
	}

	/**
	  * Manually tested. This is complicated but it works.
	  *
	  * @todo Could probably get rid of the complicated API call and math, and just read what the wikicode result of {{subst:GAR}} was in a previous step. Its |page= parameter either has the highest existing subpage # or the first empty subpage #. In other words, that template does the same calculation, so no reason to do it twice.
	  */
	getNextUnusedGASubpageTitle(listOfPages, mainArticleTitle) {
		// delete all non-numeric characters. will make sorting easier
		listOfPages = listOfPages.map(v => {
			let number = v.match(/(\d+)$/)[1];
			number = parseInt(number);
			return number;
		});

		// sort the array numerically, not lexographically
		listOfPages = this.sortNumerically(listOfPages);

		let highestSubpageNumber = listOfPages.length ? listOfPages[listOfPages.length - 1] : 0;
		let newSubpageNumber = highestSubpageNumber + 1;
		return `Talk:${mainArticleTitle}/GA${newSubpageNumber}`;
	}

	/**
	  * @param {Array} listOfNumbers
	  * CC BY-SA 4.0, aks, https://stackoverflow.com/a/1063027/3480193
	  */
	sortNumerically(listOfNumbers) {
		return listOfNumbers.sort(function(a, b,) {
			return a - b;
		});
	}

	/**
	  * @return {Promise<array>} listOfPages
	  */
	async getAllSubpagesStartingWith(searchPrefixNoNamespace) {
		let api = new this.mw.Api();
		let params = {
			"action": "query",
			"format": "json",
			"list": "allpages",
			"formatversion": "2",
			"apprefix": searchPrefixNoNamespace,
			"apnamespace": "1", // article talk
			"aplimit": "max"
		};
		let result = await api.post(params);
		let allPages = result['query']['allpages'];
		let listOfPages = [];
		for ( let key in allPages ) {
			listOfPages.push(allPages[key]['title']);
		}
		return listOfPages;
	}

	showHTMLForm() {
		let formHTML = `

<style>
	#MassGARTool {
		border: 1px solid black;
		padding: 1em;
		margin-bottom: 1em;
	}

	#MassGARTool h2 {
		margin-top: 0;
	}

	#MassGARTool strong {
		text-decoration: underline;
	}

	#MassGARTool p {
		margin-top: 1.5em;
		margin-bottom: 1.5em;
		line-height: 1.5em;
	}

	#MassGARTool-Status {
		display: none;
	}

	.MassGARTool-ErrorNotice {
		color: red;
		font-weight: bold;
	}

	#MassGARTool textarea {
		height: auto;
	}

	#MassGARTool input[type="text"] {
		width: 100%;
	}
</style>

<div id="MassGARTool">
	<div id="MassGARTool-Form">
		<h2>
			Mass GAR Tool
		</h2>

		<p>
		This tool currently creates individual reassessment pages, then de-lists them all. It skips notifying creator, notifying nominator, and transcluding the assessment to the article talk page. Individual reassessment is deprecated, but is often better for mass delisting because then it won't spam the community reassessment GAR logs. Anyway, this code may need adjusting for future mass GARs.
		</p>

		<p>
		To follow API etiquette, there is a 10 second edit throttle. So this page will go a bit slow. Please leave this tab open while the bot is running. Closing this tab will stop the bot.
		</p>

		<p>
			<strong>Edit summary</strong><br />
			<input id="MassGARTool-EditSummary" type="text" />
		</p>

		<p>
			<strong>Individual reassessment page wikicode</strong><br />
			<textarea id="MassGARTool-IndividualReassessmentPageWikicode" rows="5"></textarea>
		</p>

		<p>
			<strong>List of GARs</strong><br />
			Separate with line breaks
			<textarea id="MassGARTool-ListOfGARs" rows="10"></textarea>
		</p>

		<p>
			<button id="MassGARTool-Run">Run</button>
		</p>
	</div>

	<div id="MassGARTool-Status">
		<p>
		</p>
	</div>
</div>

		`;

		let defaultEditSummary = `mass delist certain GAs per [[Wikipedia:Good article reassessment/February 2023]] (NovemBot Task 6)`;

		let defaultIndividualReassessmentPageWikicode = '{{subst:Wikipedia:Good article reassessment/February 2023/GAR notice}}';

		/*
		let defaultListOfGARs =
`Julius Kahn (inventor)
Trussed Concrete Steel Company`;

		let defaultListOfGARs =
`Julius Kahn (inventor)`;
		*/
		let defaultListOfGARs =
`Julius Kahn (inventor)
Trussed Concrete Steel Company`;

		this.$('.mw-parser-output').after(formHTML);
		this.$('#MassGARTool-EditSummary').val(defaultEditSummary);
		this.$('#MassGARTool-IndividualReassessmentPageWikicode').val(defaultIndividualReassessmentPageWikicode);
		this.$('#MassGARTool-ListOfGARs').val(defaultListOfGARs);
	}

	isCorrectPage() {
		let currentPageTitle = this.mw.config.get('wgPageName').replace(/_/g, ' ');
		if ( currentPageTitle === 'User:Novem Linguae/Scripts/GANReviewTool/MassGAR' ) {
			return true;
		}
		return false;
	}

	isAuthorizedUser() {
		let username = this.mw.config.get('wgUserName');
		if ( username === 'Novem Linguae' || username === 'NovemBot' ) {
			return true;
		}
		return false;
	}

	pushStatus(statusToAdd) {
		this.$(`#MassGARTool-Status`).show();
		this.$(`#MassGARTool-Status > p`).append('<br />' + statusToAdd);
	}

	async getWikicode(title) {
		let api = new this.mw.Api();
		let params = {
			"action": "parse",
			"page": title,
			"prop": "wikitext",
			"format": "json",
		};
		let result;
		try {
			result = await api.post(params);
		} catch (e) {
			if ( e === 'missingtitle' ) {
				throw new Error(`${title}: does not appear to be created yet.`);
			} else {
				throw e;
			}
		}
		let wikicode = result['parse']['wikitext']['*'];
		return wikicode;
	}

	async makeEdit(title, editSummary, wikicode) {
		// API etiquette. 10 second delay between edits.
		await this.delay(this.editThrottleInSeconds);

		let api = new this.mw.Api();
		let params = {
			"action": "edit",
			"format": "json",
			"title": title,
			"text": wikicode,
			"summary": editSummary,
		};
		let result = await api.postWithToken('csrf', params);
		let revisionID = result['edit']['newrevid'];
		return revisionID;
	}

	async prependEdit(title, editSummary, wikicode) {
		// API etiquette. 10 second delay between edits.
		await this.delay(this.editThrottleInSeconds);

		let api = new this.mw.Api();
		let params = {
			"action": "edit",
			"format": "json",
			"title": title,
			"prependtext": wikicode,
			"summary": editSummary,
		};
		let result = await api.postWithToken('csrf', params);
		let revisionID = result['edit']['newrevid'];
		return revisionID;
	}

	async delay(seconds) {
		let milliseconds = seconds * 1000;
		return new Promise(function (res) {
			setTimeout(res, milliseconds);
		});
	}
}

// === modules/MassGARWikicodeGenerator.js ======================================================

class MassGARWikicodeGenerator {
	hasGoodArticleTemplate(mainArticleWikicode) {
		let gaTemplateNames = ['ga icon', 'ga article', 'good article'];
		return this._wikicodeHasTemplate(mainArticleWikicode, gaTemplateNames);
	}

	talkPageIndicatesGA(talkPageWikicode) {
		// Check for {{GA}}
		let gaTemplateNames = ['GA'];
		if ( this._wikicodeHasTemplate(talkPageWikicode, gaTemplateNames) ) {
			return true;
		}

		// Check for {{Article history|currentstatus=GA}}
		// TODO: currently just checks for |currentstatus=GA anywhere on the page. Could improve this algorithm if there end up being false positives.
		let matches = talkPageWikicode.match(/\|\s*currentstatus\s*=\s*GA\b/i);
		if ( matches ) {
			return true;
		}
		return false;
	}

	hasOpenGAR(talkPageWikicode) {
		let garTemplateNames = ['GAR/link'];
		return this._wikicodeHasTemplate(talkPageWikicode, garTemplateNames);
	}

	/**
	  * @param {string} wikicode
	  * @param {Array} listOfTemplates Case insensitive.
	  */
	_wikicodeHasTemplate(wikicode, listOfTemplates) {
		let stringForRegEx = listOfTemplates
			.map(v => this._regExEscape(v))
			.join('|');
		let regex = new RegExp(`{{(?:${stringForRegEx})\\b`, 'i');
		let matches = wikicode.match(regex);
		if ( matches ) {
			return true;
		}
		return false;
	}

	/**
	 * CC BY-SA 4.0, coolaj86, https://stackoverflow.com/a/6969486/3480193
	 */
	_regExEscape(string) {
		return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
	}
}

// === modules/TemplateFinder.js ======================================================

// TODO: A couple of recent bugs will require a lexer or template parser type class to solve.

class TemplateFinder {
	// getTemplateList()
	// appendParameter()
	// addWikitextAfterTemplate()
	// getWikitext()

	// appends/adds need to shift all the position variables by the length of the append/add
}

});

// </nowiki>