Jump to content

User:Novem Linguae/Scripts/GANReviewTool.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Novem Linguae (talk | contribs) at 17:58, 11 March 2023 (collapse GANReviewTool by default (publish.php)). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
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 ======================================================

// 


$(async function() {
	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 {function} $ 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) {
		if ( arguments.length !== 5 ) throw new Error('Incorrect # of arguments');

		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;

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

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

	async clickSubmit() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		this.readFormAndSetVariables();

		let 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) {
			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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

		await this.processOnHoldForTalkPage();
	}

	async askSecondOpinion() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

		await this.processAskSecondOpinionForTalkPage();
	}

	async answerSecondOpinion() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

		await this.processAnswerSecondOpinionForTalkPage();
	}

	async processOnHoldForTalkPage() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

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

	async processFailForGANPage() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		this.pushStatus('Adding to appropriate subpage of [[WP:GA]]');
		let gaSubpageLongTitle = `Wikipedia:Good articles/` + this.gaSubpageShortTitle;
		let 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);
		let gaSubPageEditSummary = this.getGASubPageEditSummary(this.editSummary, this.detailedTopic);
		this.gaRevisionID = await this.makeEdit(gaSubpageLongTitle, gaSubPageEditSummary, gaSubpageWikicode);
	}

	async processPassForGANPage() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		let api = new this.mw.Api();
		let params = {
			"action": "query",
			"format": "json",
			"prop": "revisions",
			"titles": pageTitle,
			"formatversion": "2",
			"rvlimit": "1",
			"rvdir": "older"
		};
		let result = await api.post(params);
		let revisionID = result['query']['pages'][0]['revisions'][0]['revid'];
		return revisionID;
	}
	
	readFormAndSetVariables() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

	displayForm() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		this.$('#mw-content-text').prepend(this.hg.getHTML(this.gaTitle));
	}

	async shouldRunOnThisPageSlowChecks() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		// only run if this review hasn't already been closed. check for {{atop}}
		let 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);
		let talkWikicode = await this.getWikicode(this.gaTalkTitle);
		if ( this.ganReviewPageTitle !== 'User:Novem_Linguae/sandbox' && ! talkWikicode.match(/\{\{GA nominee/i) ) {
			return false;
		}

		return true;
	}
	
	async writeToLog() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		// always log no matter what. hopefully log some errors so I can fix them
		this.pushStatus('Adding to log');
		let username = this.mw.config.get('wgUserName');
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	async makeEdit(title, editSummary, wikicode) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

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

	/**
	 * Lets you append without getting the Wikicode first. Saves an API query.
	 */
	async appendToPage(title, editSummary, wikicodeToAppend) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

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

	pushStatus(statusToAdd) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	shouldRunOnThisPageQuickChecks(title) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		// don't run when not viewing articles
		let action = this.mw.config.get('wgAction');
		if ( action != 'view' ) return false;
		
		// don't run when viewing diffs
		let isDiff = this.mw.config.get('wgDiffNewId');
		if ( isDiff ) return false;
		
		let isDeletedPage = ( ! this.mw.config.get('wgCurRevisionId') );
		if ( isDeletedPage ) return false;

		// always run in Novem's sandbox
		if ( title === 'User:Novem_Linguae/sandbox' ) return true;
		
		// only run in talk namespace
		let namespace = this.mw.config.get('wgNamespaceNumber');
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	getGATitle(title) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	getGATalkTitle(gaTitle) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		if ( gaTitle.includes(':') ) {
			return gaTitle.replace(/^([^:]*)(:.*)$/gm, '$1 talk$2');
		} else {
			return 'Talk:' + gaTitle;
		}
	}

	/**
	 * @param {string} detailedTopic The heading name, with leading and trailing === to denote it as a heading
	 */
	getGASubPageEditSummary(editSummary, detailedTopic) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		// 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) {
		let defaultDisplayText = this.getDefaultDisplayText(gaTitle);
		defaultDisplayText = this.escapeHtml(defaultDisplayText);

		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>

							<option value="Agriculture, food and drink" disabled>==Agriculture, food, and drink==</option>
							<option value="Agriculture, food and drink" disabled>===Agriculture, food, and drink===</option>
							<option value="Agriculture, food and drink">=====Agriculture and farming=====</option>
							<option value="Agriculture, food and drink">=====Horticulture and forestry=====</option>
							<option value="Agriculture, food and drink">=====Food and drink establishments=====</option>
							<option value="Agriculture, food and drink">=====Cuisines=====</option>
							<option value="Agriculture, food and drink">=====Food=====</option>
							<option value="Agriculture, food and drink">=====Drink=====</option>
							<option value="Agriculture, food and drink">=====Food and drink companies=====</option>
							<option value="Agriculture, food and drink">=====Food and drink people=====</option>
							<option value="Agriculture, food and drink">=====Cookery books=====</option>
							<option value="Agriculture, food and drink">=====Miscellaneous=====</option>

							<option value="Art and architecture" disabled>==Art and architecture==</option>
							<option value="Art and architecture" disabled>===Art===</option>
							<option value="Art and architecture">=====Art=====</option>
							<option value="Art and architecture">=====Artists and art organizations=====</option>
							<option value="Art and architecture" disabled>===Architecture===</option>
							<option value="Art and architecture">=====Architecture=====</option>
							<option value="Art and architecture">=====Architecture – Bridges and tunnels=====</option>
							<option value="Art and architecture">=====Architecture – Buildings=====</option>
							<option value="Art and architecture">=====Architecture – Hotels and inns=====</option>
							<option value="Art and architecture">=====Architecture – Houses and residences=====</option>
							<option value="Art and architecture">=====Architecture – Lighthouses=====</option>
							<option value="Art and architecture">=====Architecture – Memorials and monuments=====</option>
							<option value="Art and architecture">=====Architecture – Museums and galleries=====</option>
							<option value="Art and architecture">=====Architecture – Religious=====</option>
							<option value="Art and architecture">=====Architects=====</option>

							<option value="Engineering and technology" disabled>==Engineering and technology==</option>
							<option value="Engineering and technology" disabled>===Computing and engineering===</option>
							<option value="Engineering and technology">=====Computer-related organizations and people=====</option>
							<option value="Engineering and technology">=====Cryptography=====</option>
							<option value="Engineering and technology">=====Engineers and inventors=====</option>
							<option value="Engineering and technology">=====Engineering technology=====</option>
							<option value="Engineering and technology">=====Engineering failures and disasters=====</option>
							<option value="Engineering and technology">=====Hardware, standards and protocols=====</option>
							<option value="Engineering and technology">=====Power and water infrastructure=====</option>
							<option value="Engineering and technology">=====Programming=====</option>
							<option value="Engineering and technology">=====Software=====</option>
							<option value="Engineering and technology">=====Websites and the Internet=====</option>
							<option value="Engineering and technology" disabled>===Transport===</option>
							<option value="Engineering and technology">====Air transport====</option>
							<option value="Engineering and technology">=====Air transport people=====</option>
							<option value="Engineering and technology">=====Civilian aircraft=====</option>
							<option value="Engineering and technology">=====Airlines=====</option>
							<option value="Engineering and technology">=====Airports and terminals =====</option>
							<option value="Engineering and technology">=====Aviation incidents=====</option>
							<option value="Engineering and technology">====Maritime transport====</option>
							<option value="Engineering and technology">=====Civilian ships=====</option>
							<option value="Engineering and technology">====Rail transport====</option>
							<option value="Engineering and technology">=====Rail bridges, tunnels, and stations=====</option>
							<option value="Engineering and technology">=====Trains and locomotives=====</option>
							<option value="Engineering and technology" disabled>====Road infrastructure====</option>
							<option value="Engineering and technology">=====Road infrastructure: Canada=====</option>
							<option value="Engineering and technology">=====Road infrastructure: United States=====</option>
							<option value="Engineering and technology">=====Road infrastructure: Northeastern United States=====</option>
							<option value="Engineering and technology">=====Road infrastructure: Southern United States=====</option>
							<option value="Engineering and technology">=====Road infrastructure: Midwestern United States=====</option>
							<option value="Engineering and technology">=====Road infrastructure: Western United States=====</option>
							<option value="Engineering and technology">=====Road infrastructure: Other=====</option>
							<option value="Engineering and technology">=====Road transportation: Buses, vans, and paratransit=====</option>
							<option value="Engineering and technology">=====Road transportation: Cars and motorcycles=====</option>
							<option value="Engineering and technology">=====Road transportation: Other=====</option>
							<option value="Engineering and technology">=====Transport by region=====</option>

							<option value="Geography and places" disabled>==Geography and places==</option>
							<option value="Geography and places" disabled>===Geography===</option>
							<option value="Geography and places">=====Bodies of water and water formations=====</option>
							<option value="Geography and places">=====Geographers and explorers=====</option>
							<option value="Geography and places">=====General and human geography=====</option>
							<option value="Geography and places">=====Islands=====</option>
							<option value="Geography and places">=====Landforms=====</option>
							<option value="Geography and places">=====National and state parks, nature reserves, conservation areas, and countryside routes=====</option>
							<option value="Geography and places">=====Urban and historical sites=====</option>
							<option value="Geography and places">=====Geography miscellanea=====</option>
							<option value="Geography and places" disabled>===Places===</option>
							<option value="Geography and places">=====Countries and Regions=====</option>
							<option value="Geography and places">=====Africa=====</option>
							<option value="Geography and places">=====Antarctica=====</option>
							<option value="Geography and places">=====Asia=====</option>
							<option value="Geography and places">=====Australia and the Pacific=====</option>
							<option value="Geography and places">=====Europe=====</option>
							<option value="Geography and places">=====North America=====</option>
							<option value="Geography and places">=====South America=====</option>

							<option value="History" disabled>==History==</option>
							<option value="History" disabled>===World history===</option>
							<option value="History">=====Archaeology and archaeologists=====</option>
							<option value="History">=====Historians, chroniclers and history books=====</option>
							<option value="History">=====Historical figures: heads of state and heads of government=====</option>
							<option value="History">=====Historical figures: politicians=====</option>
							<option value="History">=====Historical figures: other=====</option>
							<option value="History">=====African history=====</option>
							<option value="History">=====North American history=====</option>
							<option value="History">=====South American history=====</option>
							<option value="History">=====Asian history=====</option>
							<option value="History">=====Australian and Oceania history=====</option>
							<option value="History">=====European history=====</option>
							<option value="History">=====Middle Eastern history=====</option>
							<option value="History">=====Global history=====</option>
							<option value="History" disabled>===Royalty, nobility, and heraldry===</option>
							<option value="History">=====Flags and heraldry=====</option>
							<option value="History">=====Monarchs=====</option>
							<option value="History">=====Royalty and nobility=====</option>

							<option value="Language and literature" disabled>==Language and literature==</option>
							<option value="Language and literature" disabled>===Language and literature===</option>
							<option value="Language and literature">=====Alphabets and transliteration=====</option>
							<option value="Language and literature">=====Ancient texts=====</option>
							<option value="Language and literature">=====Biographies, autobiographies, essays, diaries, and travelogues=====</option>
							<option value="Language and literature">=====Characters and fictional items=====</option>
							<option value="Language and literature">=====Children's books, fairy tales, and nursery rhymes=====</option>
							<option value="Language and literature">=====Comics=====</option>
							<option value="Language and literature">=====Genres and literary theory=====</option>
							<option value="Language and literature">=====Languages=====</option>
							<option value="Language and literature">=====Linguists and philologists=====</option>
							<option value="Language and literature">=====Non-fiction=====</option>
							<option value="Language and literature">=====Novels=====</option>
							<option value="Language and literature">=====Plays=====</option>
							<option value="Language and literature">=====Poetry=====</option>
							<option value="Language and literature">=====Short fiction and anthologies=====</option>
							<option value="Language and literature">=====Tolkien=====</option>
							<option value="Language and literature">=====Words and linguistics=====</option>
							<option value="Language and literature">=====Writers, publishers, and critics=====</option>

							<option value="Mathematics" disabled>==Mathematics==</option>
							<option value="Mathematics" disabled>===Mathematics and mathematicians===</option>
							<option value="Mathematics">=====Mathematical concepts and topics=====</option>
							<option value="Mathematics">=====Mathematical texts and artifacts=====</option>
							<option value="Mathematics">=====Mathematicians=====</option>

							<option value="Media and drama" disabled>==Media and drama==</option>
							<option value="Media and drama" disabled>===Film===</option>
							<option value="Media and drama">=====Cinema=====</option>
							<option value="Media and drama">=====Film franchises, overview articles and production articles=====</option>
							<option value="Media and drama">=====Pre-1910s films=====</option>
							<option value="Media and drama">=====1910s films=====</option>
							<option value="Media and drama">=====1920s films=====</option>
							<option value="Media and drama">=====1930s films=====</option>
							<option value="Media and drama">=====1940s films=====</option>
							<option value="Media and drama">=====1950s films=====</option>
							<option value="Media and drama">=====1960s films=====</option>
							<option value="Media and drama">=====1970s films=====</option>
							<option value="Media and drama">=====1980s films=====</option>
							<option value="Media and drama">=====1990s films=====</option>
							<option value="Media and drama">=====2000 to 2004 films=====</option>
							<option value="Media and drama">=====2005 to 2009 films=====</option>
							<option value="Media and drama">=====2010 to 2014 films=====</option>
							<option value="Media and drama">=====2015 to 2019 films=====</option>
							<option value="Media and drama">=====2020 to 2024 films=====</option>
							<option value="Media and drama" disabled>===Television===</option>
							<option value="Media and drama">=====Television networks and overview articles=====</option>
							<option value="Media and drama">====Television series====</option>
							<option value="Media and drama">=====''30 Rock''=====</option>
							<option value="Media and drama">=====''Ackley Bridge''=====</option>
							<option value="Media and drama">=====''Adventure Time''=====</option>
							<option value="Media and drama">=====''American Dad!''=====</option>
							<option value="Media and drama">=====''American Horror Story''=====</option>
							<option value="Media and drama">=====''Archer''=====</option>
							<option value="Media and drama">=====Arrowverse=====</option>
							<option value="Media and drama">=====Avatarverse=====</option>
							<option value="Media and drama">=====''Awake''=====</option>
							<option value="Media and drama">=====''Battlestar Galactica''=====</option>
							<option value="Media and drama">=====''Better Call Saul''=====</option>
							<option value="Media and drama">=====''The Big Bang Theory''=====</option>
							<option value="Media and drama">=====''Black Mirror''=====</option>
							<option value="Media and drama">=====''Body of Proof''=====</option>
							<option value="Media and drama">=====''BoJack Horseman''=====</option>
							<option value="Media and drama">=====''Breaking Bad''=====</option>
							<option value="Media and drama">=====''Buffy the Vampire Slayer''=====</option>
							<option value="Media and drama">=====''Casualty''=====</option>
							<option value="Media and drama">=====''Cheers''=====</option>
							<option value="Media and drama">=====''Chuck''=====</option>
							<option value="Media and drama">=====''Cold Feet''=====</option>
							<option value="Media and drama">=====''Community''=====</option>
							<option value="Media and drama">=====''Coronation Street''=====</option>
							<option value="Media and drama">=====''Daredevil''=====</option>
							<option value="Media and drama">=====''Desperate Housewives''=====</option>
							<option value="Media and drama">=====''Dexter''=====</option>
							<option value="Media and drama">=====''Doctor Who'' series=====</option>
							<option value="Media and drama">=====''Doctor Who'' episodes=====</option>
							<option value="Media and drama">=====''Doctors''=====</option>
							<option value="Media and drama">=====''EastEnders''=====</option>
							<option value="Media and drama">=====''Ed, Edd n Eddy''=====</option>
							<option value="Media and drama">=====''Emmerdale''=====</option>
							<option value="Media and drama">=====''Family Guy''=====</option>
							<option value="Media and drama">=====''Friends''=====</option>
							<option value="Media and drama">=====''Fringe'' series=====</option>
							<option value="Media and drama">=====''Fringe'' episodes=====</option>
							<option value="Media and drama">=====''Futurama''=====</option>
							<option value="Media and drama">=====''Game of Thrones''=====</option>
							<option value="Media and drama">=====''Glee'' series=====</option>
							<option value="Media and drama">=====''Glee'' episodes=====</option>
							<option value="Media and drama">=====''The Good Place''=====</option>
							<option value="Media and drama">=====''Gossip Girl''=====</option>
							<option value="Media and drama">=====''Grey's Anatomy'' series=====</option>
							<option value="Media and drama">=====''Grey's Anatomy'' episodes=====</option>
							<option value="Media and drama">=====''Hawaii Five-0 (2010 TV series)''=====</option>
							<option value="Media and drama">=====''The Hills''=====</option>
							<option value="Media and drama">=====''Home and Away''=====</option>
							<option value="Media and drama">=====''Holby City''=====</option>
							<option value="Media and drama">=====''Hollyoaks''=====</option>
							<option value="Media and drama">=====''Homicide: Life on the Street''=====</option>
							<option value="Media and drama">=====''House''=====</option>
							<option value="Media and drama">=====''House of Cards''=====</option>
							<option value="Media and drama">=====''The House of Flowers''=====</option>
							<option value="Media and drama">=====''Inside No. 9''=====</option>
							<option value="Media and drama">=====''Last Week Tonight with John Oliver''=====</option>
							<option value="Media and drama">=====''Law & Order: Special Victims Unit''=====</option>
							<option value="Media and drama">=====''Lost'' series=====</option>
							<option value="Media and drama">=====''Lost'' episodes=====</option>
							<option value="Media and drama">=====''Mad Men''=====</option>
							<option value="Media and drama">=====''Magnum P.I.''=====</option>
							<option value="Media and drama">=====Marvel Cinematic Universe=====</option>
							<option value="Media and drama">=====''Millennium''=====</option>
							<option value="Media and drama">=====''Modern Family''=====</option>
							<option value="Media and drama">=====''Monk''=====</option>
							<option value="Media and drama">=====''My Little Pony: Friendship Is Magic''=====</option>
							<option value="Media and drama">=====''Neighbours''=====</option>
							<option value="Media and drama">=====''Neon Genesis Evangelion''=====</option>
							<option value="Media and drama">=====''The Office'' series=====</option>
							<option value="Media and drama">=====''The Office'' episodes=====</option>
							<option value="Media and drama">=====''Once Upon a Time''=====</option>
							<option value="Media and drama">=====''Parks and Recreation''=====</option>
							<option value="Media and drama">=====''Phineas and Ferb''=====</option>
							<option value="Media and drama">=====''Psych''=====</option>
							<option value="Media and drama">=====''Rugrats''=====</option>
							<option value="Media and drama">=====''Sanctuary''=====</option>
							<option value="Media and drama">=====''Seinfeld''=====</option>
							<option value="Media and drama">=====''Sesame Street'' series and co-productions=====</option>
							<option value="Media and drama">=====''The Simpsons'' series=====</option>
							<option value="Media and drama">=====''The Simpsons'' episodes=====</option>
							<option value="Media and drama">=====''Skins''=====</option>
							<option value="Media and drama">=====''Smallville''=====</option>
							<option value="Media and drama">=====''South Park'' series=====</option>
							<option value="Media and drama">=====''South Park'' episodes=====</option>
							<option value="Media and drama">=====''The Spectacular Spider-Man''=====</option>
							<option value="Media and drama">=====''SpongeBob SquarePants''=====</option>
							<option value="Media and drama">=====''Spooks''=====</option>
							<option value="Media and drama">=====''Stargate''=====</option>
							<option value="Media and drama">=====''Star Trek'' series=====</option>
							<option value="Media and drama">=====''Star Trek'' series episodes=====</option>
							<option value="Media and drama">=====''Supernatural''=====</option>
							<option value="Media and drama">=====''Thunderbirds''=====</option>
							<option value="Media and drama">=====''Torchwood''=====</option>
							<option value="Media and drama">=====''Twin Peaks''=====</option>
							<option value="Media and drama">=====''Ugly Americans''=====</option>
							<option value="Media and drama">=====''Veronica Mars''=====</option>
							<option value="Media and drama">=====''The Walking Dead''=====</option>
							<option value="Media and drama">=====''WandaVision''=====</option>
							<option value="Media and drama">=====''The West Wing''=====</option>
							<option value="Media and drama">=====''White Collar''=====</option>
							<option value="Media and drama">=====''Will & Grace''=====</option>
							<option value="Media and drama">=====''The X-Files'' series=====</option>
							<option value="Media and drama">=====''The X-Files'' episodes=====</option>
							<option value="Media and drama">=====Other television series, 1950s debuts=====</option>
							<option value="Media and drama">=====Other television series, 1960s debuts=====</option>
							<option value="Media and drama">=====Other television series, 1970s debuts=====</option>
							<option value="Media and drama">=====Other television series, 1980s debuts=====</option>
							<option value="Media and drama">=====Other television series, 1990s debuts=====</option>
							<option value="Media and drama">=====Other television series, 2000s debuts=====</option>
							<option value="Media and drama">=====Other television series, 2010s debuts=====</option>
							<option value="Media and drama">=====Other television series, 2020s debuts=====</option>
							<option value="Media and drama">=====Other television seasons and related articles=====</option>
							<option value="Media and drama">=====Other episodes and specials=====</option>
							<option value="Media and drama" disabled>===Media and drama===</option>
							<option value="Media and drama">=====Actors, directors, models, performers, and celebrities=====</option>
							<option value="Media and drama">=====Animation=====</option>
							<option value="Media and drama">=====Fictional characters and technologies=====</option>
							<option value="Media and drama">=====Radio=====</option>
							<option value="Media and drama">=====Theatre, musical theatre, dance, and opera=====</option>

							<option value="Music" disabled>==Music==</option>
							<option value="Music" disabled>===Albums===</option>
							<option value="Music">=====1950 to 1969 albums=====</option>
							<option value="Music">=====1970 to 1979 albums=====</option>
							<option value="Music">=====1980 to 1989 albums=====</option>
							<option value="Music">=====1990 to 1994 albums=====</option>
							<option value="Music">=====1995 to 1999 albums=====</option>
							<option value="Music">=====2000 to 2004 albums=====</option>
							<option value="Music">=====2005 to 2009 albums=====</option>
							<option value="Music">=====2010 to 2014 albums=====</option>
							<option value="Music">=====2015 to 2019 albums=====</option>
							<option value="Music">=====2020 to 2024 albums=====</option>
							<option value="Music">=====Soundtracks=====</option>
							<option value="Music">=====Video albums=====</option>
							<option value="Music" disabled>===Classical compositions===</option>
							<option value="Music">=====Classical compositions=====</option>
							<option value="Music" disabled>===Songs===</option>
							<option value="Music">=====Pre-1900 songs=====</option>
							<option value="Music">=====1900 to 1959 songs=====</option>
							<option value="Music">=====1960 to 1969 songs=====</option>
							<option value="Music">=====1970 to 1979 songs=====</option>
							<option value="Music">=====1980 to 1989 songs=====</option>
							<option value="Music">=====1990 to 1999 songs=====</option>
							<option value="Music">=====2000 to 2004 songs=====</option>
							<option value="Music">=====2005 to 2006 songs=====</option>
							<option value="Music">=====2007 to 2008 songs=====</option>
							<option value="Music">=====2009 songs=====</option>
							<option value="Music">=====2010 songs=====</option>
							<option value="Music">=====2011 songs=====</option>
							<option value="Music">=====2012 songs=====</option>
							<option value="Music">=====2013 songs=====</option>
							<option value="Music">=====2014 songs=====</option>
							<option value="Music">=====2015 songs=====</option>
							<option value="Music">=====2016 songs=====</option>
							<option value="Music">=====2017 songs=====</option>
							<option value="Music">=====2018 songs=====</option>
							<option value="Music">=====2019 songs=====</option>
							<option value="Music">=====2020 songs=====</option>
							<option value="Music">=====2021 songs=====</option>
							<option value="Music">=====2022 songs=====</option>
							<option value="Music" disabled>===Other music articles===</option>
							<option value="Music">=====Music awards=====</option>
							<option value="Music">=====Music by nation, people, region, or country=====</option>
							<option value="Music">=====Music genres, music styles, music eras=====</option>
							<option value="Music">=====Musical theory, musical instruments, and music techniques=====</option>
							<option value="Music">=====Music businesses and events=====</option>
							<option value="Music">=====Performers, groups, composers, and other music-related people=====</option>

							<option value="Natural sciences" disabled>==Natural sciences==</option>
							<option value="Natural sciences" disabled>===Biology and medicine===</option>
							<option value="Natural sciences">====Biology====</option>
							<option value="Natural sciences">=====Anatomy=====</option>
							<option value="Natural sciences">=====Biologists=====</option>
							<option value="Natural sciences">=====Biology books=====</option>
							<option value="Natural sciences">=====Ecology=====</option>
							<option value="Natural sciences">=====Evolution=====</option>
							<option value="Natural sciences">=====Molecular and cellular biology=====</option>
							<option value="Natural sciences">=====Miscellaneous biology=====</option>
							<option value="Natural sciences">====Medicine====</option>
							<option value="Natural sciences">=====Medicine books=====</option>
							<option value="Natural sciences">=====Diseases and medical conditions=====</option>
							<option value="Natural sciences">=====History of medicine=====</option>
							<option value="Natural sciences">=====Medical people and institutions=====</option>
							<option value="Natural sciences">=====Medical procedures=====</option>
							<option value="Natural sciences">====Pharmacology====</option>
							<option value="Natural sciences">=====Vaccines=====</option>
							<option value="Natural sciences">=====Drug classes and individual drugs=====</option>
							<option value="Natural sciences">=====Pharmacology miscellanea=====</option>
							<option value="Natural sciences">====Viruses====</option>
							<option value="Natural sciences">====Organisms====</option>
							<option value="Natural sciences">=====Bacterial species=====</option>
							<option value="Natural sciences">=====Protists=====</option>
							<option value="Natural sciences">=====Fungi=====</option>
							<option value="Natural sciences">=====Plants=====</option>
							<option value="Natural sciences">=====Animals=====</option>
							<option value="Natural sciences">======Mammals and other synapsids======</option>
							<option value="Natural sciences">======Birds======</option>
							<option value="Natural sciences">======Non-avian dinosaurs======</option>
							<option value="Natural sciences">======Reptiles and amphibians======</option>
							<option value="Natural sciences">======Fish======</option>
							<option value="Natural sciences">======Arthropods======</option>
							<option value="Natural sciences">======Other invertebrates======</option>
							<option value="Natural sciences">======Animal domestic breeds, types, and individuals======</option>
							<option value="Natural sciences" disabled>===Chemistry and materials science===</option>
							<option value="Natural sciences">=====Areas of chemistry theory=====</option>
							<option value="Natural sciences">=====Chemistry books=====</option>
							<option value="Natural sciences">=====Types of chemical analyses=====</option>
							<option value="Natural sciences">=====Types of chemical transformations=====</option>
							<option value="Natural sciences">=====Named reactions=====</option>
							<option value="Natural sciences">=====Classes of chemical compounds and materials=====</option>
							<option value="Natural sciences">=====Chemical compounds and materials=====</option>
							<option value="Natural sciences">=====Periodic table groups and periods=====</option>
							<option value="Natural sciences">=====Elements=====</option>
							<option value="Natural sciences">=====Chemistry and materials science organizations=====</option>
							<option value="Natural sciences">=====Chemists and materials scientists=====</option>
							<option value="Natural sciences">=====Chemistry miscellanea=====</option>
							<option value="Natural sciences">=====Materials science miscellanea=====</option>
							<option value="Natural sciences" disabled>===Earth science===</option>
							<option value="Natural sciences" disabled>====Geology====</option>
							<option value="Natural sciences">=====Geology and geophysics=====</option>
							<option value="Natural sciences">=====Geologists, geophysicists and mineralogists=====</option>
							<option value="Natural sciences">=====Mineralogy=====</option>
							<option value="Natural sciences">=====Earthquakes and similar natural disasters=====</option>
							<option value="Natural sciences">====Meteorology====</option>
							<option value="Natural sciences">=====Climate=====</option>
							<option value="Natural sciences">=====Climate change=====</option>
							<option value="Natural sciences">=====Meteorological observatories=====</option>
							<option value="Natural sciences">=====Storm sciences, tropical cyclone seasons, and storm effects=====</option>
							<option value="Natural sciences">=====Tropical cyclones: Atlantic=====</option>
							<option value="Natural sciences">=====Tropical cyclones: Eastern Pacific=====</option>
							<option value="Natural sciences">=====Tropical cyclones: Northwestern Pacific=====</option>
							<option value="Natural sciences">=====Tropical cyclones: Southern Pacific and the Indian Ocean=====</option>
							<option value="Natural sciences">=====Weather=====</option>
							<option value="Natural sciences">=====Severe weather and winter storms=====</option>
							<option value="Natural sciences" disabled>===Physics and astronomy===</option>
							<option value="Natural sciences">====Physics====</option>
							<option value="Natural sciences">=====Physics=====</option>
							<option value="Natural sciences">=====Physicists=====</option>
							<option value="Natural sciences">====Astronomy====</option>
							<option value="Natural sciences">=====Space travelers=====</option>
							<option value="Natural sciences">=====Astronomy and astrophysics=====</option>
							<option value="Natural sciences">=====Astronomers and astrophysicists=====</option>
							<option value="Natural sciences">=====Solar system=====</option>
							<option value="Natural sciences">=====Constellations and asterisms=====</option>
							<option value="Natural sciences">=====Stars, galaxies and extrasolar objects=====</option>
							<option value="Natural sciences">=====Rocketry and spaceflight=====</option>
							<option value="Natural sciences">=====Astronomy miscellanea=====</option>

							<option value="Philosophy and religion" disabled>==Philosophy and religion==</option>
							<option value="Philosophy and religion" disabled>===Philosophy===</option>
							<option value="Philosophy and religion">=====Divinities and protohistoric figures=====</option>
							<option value="Philosophy and religion">=====Myths, mythology, and miracles=====</option>
							<option value="Philosophy and religion">=====Philosophies and philosophical movements=====</option>
							<option value="Philosophy and religion">=====Philosophical doctrines, teachings, texts, events, and symbols=====</option>
							<option value="Philosophy and religion">=====Philosophers=====</option>
							<option value="Philosophy and religion" disabled>===Religion===</option>
							<option value="Philosophy and religion">=====Religions and religious movements=====</option>
							<option value="Philosophy and religion">=====Religious congregations, denominations, and organizations=====</option>
							<option value="Philosophy and religion">=====Religious doctrines, teachings, texts, events, and symbols=====</option>
							<option value="Philosophy and religion">=====Religious figures=====</option>

							<option value="Social sciences and society" disabled>==Social sciences and society==</option>
							<option value="Social sciences and society" disabled>===Culture, sociology, and psychology===</option>
							<option value="Social sciences and society">=====Culture and cultural studies=====</option>
							<option value="Social sciences and society">=====Clothing, fashion, and beauty=====</option>
							<option value="Social sciences and society">=====Cultural symbols=====</option>
							<option value="Social sciences and society">=====Internet culture=====</option>
							<option value="Social sciences and society">=====Cultural organizations and events=====</option>
							<option value="Social sciences and society">=====Ethnic groups=====</option>
							<option value="Social sciences and society">=====Psychology and psychologists=====</option>
							<option value="Social sciences and society">=====Anthropology, anthropologists, sociology and sociologists=====</option>
							<option value="Social sciences and society" disabled>===Education===</option>
							<option value="Social sciences and society">=====Educational institutions=====</option>
							<option value="Social sciences and society">=====Educators=====</option>
							<option value="Social sciences and society">=====Education miscellanea=====</option>
							<option value="Social sciences and society" disabled>===Economics and business===</option>
							<option value="Social sciences and society">=====Advertising and marketing=====</option>
							<option value="Social sciences and society">=====Businesspeople=====</option>
							<option value="Social sciences and society">=====Businesses and organizations=====</option>
							<option value="Social sciences and society">=====Economics=====</option>
							<option value="Social sciences and society">=====Numismatics and currencies=====</option>
							<option value="Social sciences and society" disabled>===Law===</option>
							<option value="Social sciences and society">=====Case law and litigation=====</option>
							<option value="Social sciences and society">=====Constitutional law=====</option>
							<option value="Social sciences and society">=====Criminal justice, law enforcement, and ethics=====</option>
							<option value="Social sciences and society">=====Criminals, crimes, allegations, and victims=====</option>
							<option value="Social sciences and society">=====Domestic law=====</option>
							<option value="Social sciences and society">=====International laws and treaties=====</option>
							<option value="Social sciences and society">=====Lawyers, judges and legal academics=====</option>
							<option value="Social sciences and society">=====Legal institutions, publications, and buildings=====</option>
							<option value="Social sciences and society">=====Legislation and statutory law=====</option>
							<option value="Social sciences and society">=====Law miscellanea=====</option>
							<option value="Social sciences and society" disabled>===Magazines and print journalism===</option>
							<option value="Social sciences and society">=====Journalism and newspapers=====</option>
							<option value="Social sciences and society">=====Magazines and journals=====</option>
							<option value="Social sciences and society" disabled>===Politics and government===</option>
							<option value="Social sciences and society">=====Heads of state and heads of government=====</option>
							<option value="Social sciences and society">=====Spouses of heads of state and heads of government=====</option>
							<option value="Social sciences and society">=====Intelligence and espionage=====</option>
							<option value="Social sciences and society">=====International organizations=====</option>
							<option value="Social sciences and society">=====National non-governmental organizations=====</option>
							<option value="Social sciences and society">=====Political and governmental institutions=====</option>
							<option value="Social sciences and society">=====Political districts, direction and governance=====</option>
							<option value="Social sciences and society">=====Political events and elections=====</option>
							<option value="Social sciences and society">=====Political figures=====</option>
							<option value="Social sciences and society">=====Political issues, theory and analysis=====</option>
							<option value="Social sciences and society">=====Political parties and movements=====</option>

							<option value="Sports and recreation" disabled>==Sports and recreation==</option>
							<option value="Sports and recreation" disabled>===Football===</option>
							<option value="Sports and recreation">=====American football teams, events, seasons, concepts=====</option>
							<option value="Sports and recreation">=====American football people=====</option>
							<option value="Sports and recreation">=====Association football teams, events, and concepts=====</option>
							<option value="Sports and recreation">=====Association football people=====</option>
							<option value="Sports and recreation">=====Australian rules and Gaelic football=====</option>
							<option value="Sports and recreation">=====Canadian football=====</option>
							<option value="Sports and recreation">=====Rugby and rugby league football=====</option>
							<option value="Sports and recreation" disabled>===Baseball===</option>
							<option value="Sports and recreation">=====Baseball teams, venues, events, and concepts=====</option>
							<option value="Sports and recreation">=====Baseball people=====</option>
							<option value="Sports and recreation" disabled>===Basketball===</option>
							<option value="Sports and recreation">=====Basketball teams, venues and events=====</option>
							<option value="Sports and recreation">=====Basketball people=====</option>
							<option value="Sports and recreation" disabled>===Cricket===</option>
							<option value="Sports and recreation">=====Cricket teams, venues and events=====</option>
							<option value="Sports and recreation">=====Cricket people=====</option>
							<option value="Sports and recreation" disabled>===Hockey===</option>
							<option value="Sports and recreation">=====Field hockey=====</option>
							<option value="Sports and recreation">=====Ice hockey teams, venues and events=====</option>
							<option value="Sports and recreation">=====Ice hockey people=====</option>
							<option value="Sports and recreation" disabled>===Motorsport===</option>
							<option value="Sports and recreation">=====Races and seasons=====</option>
							<option value="Sports and recreation">=====Racers, racecars, and tracks=====</option>
							<option disabled>===Pro wrestling===</option>
							<option value="Sports and recreation">=====Professional wrestling events=====</option>
							<option value="Sports and recreation">=====Professional wrestling groups=====</option>
							<option value="Sports and recreation">=====Professional wrestling people=====</option>
							<option value="Sports and recreation">=====Professional wrestling championships=====</option>
							<option value="Sports and recreation">=====Professional wrestling (other)=====</option>
							<option value="Sports and recreation" disabled>===Recreation===</option>
							<option value="Sports and recreation">=====Board, card, and role-playing games=====</option>
							<option value="Sports and recreation">=====Chess=====</option>
							<option value="Sports and recreation">=====Climbing=====</option>
							<option value="Sports and recreation">=====Diving=====</option>
							<option value="Sports and recreation">=====Poker=====</option>
							<option value="Sports and recreation">=====Toys=====</option>
							<option value="Sports and recreation">=====Stadiums, public parks, and amusements=====</option>
							<option value="Sports and recreation">=====Yoga=====</option>
							<option value="Sports and recreation">=====Zoos and public aquariums=====</option>
							<option value="Sports and recreation" disabled>===Multi-sport event===</option>
							<option value="Sports and recreation">=====Olympics=====</option>
							<option value="Sports and recreation">=====Summer Olympics=====</option>
							<option value="Sports and recreation">=====Winter Olympics=====</option>
							<option value="Sports and recreation">=====Paralympics=====</option>
							<option value="Sports and recreation">=====Other multi-sport events=====</option>
							<option value="Sports and recreation" disabled>===Other sports===</option>
							<option value="Sports and recreation">=====Archery=====</option>
							<option value="Sports and recreation">=====Badminton=====</option>
							<option value="Sports and recreation">=====Cue sports=====</option>
							<option value="Sports and recreation">=====Curling=====</option>
							<option value="Sports and recreation">=====Cycling=====</option>
							<option value="Sports and recreation">=====Darts=====</option>
							<option value="Sports and recreation">=====Equestrianism/Horse racing=====</option>
							<option value="Sports and recreation">=====Fencing=====</option>
							<option value="Sports and recreation">=====Goalball=====</option>
							<option value="Sports and recreation">=====Golf=====</option>
							<option value="Sports and recreation">=====Gymnastics=====</option>
							<option value="Sports and recreation">=====Handball=====</option>
							<option value="Sports and recreation">=====Lacrosse=====</option>
							<option value="Sports and recreation">=====Mixed martial arts, martial arts, and boxing=====</option>
							<option value="Sports and recreation">=====Netball=====</option>
							<option value="Sports and recreation">=====Rowing=====</option>
							<option value="Sports and recreation">=====Running, track and field=====</option>
							<option value="Sports and recreation">=====Shooting=====</option>
							<option value="Sports and recreation">=====Skating=====</option>
							<option value="Sports and recreation">=====Skiing=====</option>
							<option value="Sports and recreation">=====Snowboarding=====</option>
							<option value="Sports and recreation">=====Softball=====</option>
							<option value="Sports and recreation">=====Squash=====</option>
							<option value="Sports and recreation">=====Swimming and water sports=====</option>
							<option value="Sports and recreation">=====Table tennis=====</option>
							<option value="Sports and recreation">=====Tennis=====</option>
							<option value="Sports and recreation">=====Volleyball=====</option>
							<option value="Sports and recreation">=====Sports mascots and supporters=====</option>
							<option value="Sports and recreation">=====Multiple sports=====</option>
							<option value="Sports and recreation">=====Sports miscellanea=====</option>

							<option value="Video games" disabled>==Video games==</option>
							<option value="Video games" disabled>===Video games===</option>
							<option value="Video games">=====Early video games=====</option>
							<option value="Video games">=====1970s video games=====</option>
							<option value="Video games">=====1980–84 video games=====</option>
							<option value="Video games">=====1985–89 video games=====</option>
							<option value="Video games">=====1990–94 video games=====</option>
							<option value="Video games">=====1995–99 video games=====</option>
							<option value="Video games">=====2000–04 video games=====</option>
							<option value="Video games">=====2005–09 video games=====</option>
							<option value="Video games">=====2010–14 video games=====</option>
							<option value="Video games">=====2015–19 video games=====</option>
							<option value="Video games">=====2020–24 video games=====</option>
							<option value="Video games">=====Cancelled video games=====</option>
							<option value="Video games">=====Video game series=====</option>
							<option value="Video games">=====Video game characters=====</option>
							<option value="Video games">=====Video game genres=====</option>
							<option value="Video games">=====Video game systems and services=====</option>
							<option value="Video games">=====Video game history and development=====</option>
							<option value="Video games">=====Video game industry and developers=====</option>
							<option value="Video games">=====Video game terms and game elements=====</option>
							<option value="Video games">=====Video game miscellanea=====</option>

							<option value="Warfare" disabled>==Warfare==</option>
							<option value="Warfare" disabled>===Armies and military units===</option>
							<option value="Warfare">====Air force====</option>
							<option value="Warfare">====Army====</option>
							<option value="Warfare">=====Australian army=====</option>
							<option value="Warfare">=====United States and Confederate armies=====</option>
							<option value="Warfare">====Navy====</option>
							<option value="Warfare">====Other====</option>
							<option value="Warfare" disabled>===Battles, exercises, and conflicts===</option>
							<option value="Warfare">====Ancient and classical history (before 500)====</option>
							<option value="Warfare">====Middle Ages (500–1499)====</option>
							<option value="Warfare">====Early modern period (1500–1799)====</option>
							<option value="Warfare">====American Revolutionary War (1775–1783)====</option>
							<option value="Warfare">====French Revolutionary and Napoleonic Wars (1792–1815)====</option>
							<option value="Warfare">====Long nineteenth century (1800–1914)====</option>
							<option value="Warfare">====World War I and interwar (1914–1939)====</option>
							<option value="Warfare">====World War II (1939–1945)====</option>
							<option value="Warfare">====Post-World War II (1945–present)====</option>
							<option value="Warfare">====Massacres, war crimes, and legal issues of warfare====</option>
							<option value="Warfare" disabled>===Military aircraft===</option>
							<option value="Warfare">====Aircraft technology and doctrine====</option>
							<option value="Warfare">====Military aircraft====</option>
							<option value="Warfare" disabled>===Military decorations and memorials===</option>
							<option value="Warfare">====Awards and decorations====</option>
							<option value="Warfare">====Military museums and memorials====</option>
							<option value="Warfare" disabled>===Military people===</option>
							<option value="Warfare">====Military people (A–C)====</option>
							<option value="Warfare">====Military people (D–F)====</option>
							<option value="Warfare">====Military people (G–K)====</option>
							<option value="Warfare">====Military people (L–M)====</option>
							<option value="Warfare">====Military people (N–R)====</option>
							<option value="Warfare">====Military people (S–Z)====</option>
							<option value="Warfare">====Warfare and race====</option>
							<option value="Warfare" disabled>===Military ranks and positions===</option>
							<option value="Warfare">====Military ranks and positions====</option>
							<option value="Warfare" disabled>===Warships and naval units===</option>
							<option value="Warfare">====Ship types====</option>
							<option value="Warfare">====Naval technology====</option>
							<option value="Warfare">====Warships====</option>
							<option value="Warfare">=====Warships of Argentina=====</option>
							<option value="Warfare">=====Warships of Australia=====</option>
							<option value="Warfare">=====Warships of Austria-Hungary=====</option>
							<option value="Warfare">=====Warships of Belgium=====</option>
							<option value="Warfare">=====Warships of Brazil=====</option>
							<option value="Warfare">=====Warships of Canada=====</option>
							<option value="Warfare">=====Warships of Chile=====</option>
							<option value="Warfare">=====Warships of China=====</option>
							<option value="Warfare">=====Warships of the Confederate States of America=====</option>
							<option value="Warfare">=====Warships of Croatia=====</option>
							<option value="Warfare">=====Warships of Denmark=====</option>
							<option value="Warfare">=====Warships of France=====</option>
							<option value="Warfare">=====Warships of Germany=====</option>
							<option value="Warfare">=====Warships of Greece=====</option>
							<option value="Warfare">=====Warships of Iceland=====</option>
							<option value="Warfare">=====Warships of India=====</option>
							<option value="Warfare">=====Warships of Indonesia=====</option>
							<option value="Warfare">=====Warships of Italy=====</option>
							<option value="Warfare">=====Warships of Japan=====</option>
							<option value="Warfare">=====Warships of Norway=====</option>
							<option value="Warfare">=====Warships of Peru=====</option>
							<option value="Warfare">=====Warships of Portugal=====</option>
							<option value="Warfare">=====Warships of Romania=====</option>
							<option value="Warfare">=====Warships of Russia and the Soviet Union=====</option>
							<option value="Warfare">=====Warships of South Africa=====</option>
							<option value="Warfare">=====Warships of Spain=====</option>
							<option value="Warfare">=====Warships of Sweden=====</option>
							<option value="Warfare">=====Warships of Turkey and the Ottoman Empire=====</option>
							<option value="Warfare">=====Warships of the United Kingdom=====</option>
							<option value="Warfare">=====Warships of the United States=====</option>
							<option value="Warfare">=====Warships of Yugoslavia=====</option>
							<option value="Warfare" disabled>===Weapons, equipment, and buildings===</option>
							<option value="Warfare">====Weapons, military equipment and programs====</option>
							<option value="Warfare">====Military uniforms and clothing====</option>
							<option value="Warfare">====Fortifications and military installations====</option>
							<option value="Warfare">====Castles====</option>
							<option value="Warfare">=====For testing purposes only. Will throw error.=====</option>
						</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) {
		let endsWithParentheticalDisambiguator = gaTitle.match(/^.+ \(.+\)$/);
		if ( ! endsWithParentheticalDisambiguator ) {
			return gaTitle;
		}
		
		let 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',
		];
		let suffixesThatTriggerDoubleQuotes = [
			'song',
		];
		let 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'
		];

		let firstHalf = gaTitle.match(/^(.+) \((.+)\)$/)[1];
		let secondHalf = gaTitle.match(/^(.+) \((.+)\)$/)[2];
		
		for ( let suffixToCheck of suffixesThatTriggerItalics ) {
			if ( gaTitle.endsWith(suffixToCheck + ')') ) {
				return `''${firstHalf}'' (${secondHalf})`;
			}
		}
		
		for ( let suffixToCheck of suffixesThatTriggerDoubleQuotes ) {
			if ( gaTitle.endsWith(suffixToCheck + ')') ) {
				return `"${firstHalf}" (${secondHalf})`;
			}
		}
		
		for ( let suffixToCheck of suffixesThatTriggerDoubleQuotesAndItalics ) {
			if ( gaTitle.endsWith(suffixToCheck + ')') ) {
				return `"${firstHalf}" (''${secondHalf}'')`;
			}
		}

		return gaTitle;
	}
}

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

class GANReviewWikicodeGenerator {
	getPassWikicodeForGANPage(reviewWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		return this.placeATOP(reviewWikicode, 'Passed. ~~~~', 'green')
	}

	getPassWikicodeForTalkPage(talkWikicode, reviewTitle, topic, oldid) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');

		// Deleting {{GA nominee}} from article talk page.
		let gaPageNumber = this.getTemplateParameter(talkWikicode, 'GA nominee', 'page');
		talkWikicode = this.deleteGANomineeTemplate(talkWikicode);

		// Adding {{GA}} 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
		let boolHasArticleHistoryTemplate = this.hasArticleHistoryTemplate(talkWikicode);
		if ( boolHasArticleHistoryTemplate ) {
			talkWikicode = this.updateArticleHistory(talkWikicode, topic, reviewTitle, 'listed', oldid);
		} else {
			talkWikicode = this.addGATemplate(talkWikicode, topic, gaPageNumber, oldid);
		}

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

		return talkWikicode;
	}

	getPassWikicodeForGAListPage(gaSubpageHeading, gaSubpageWikicode, gaTitle, gaDisplayTitle) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');

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

	getFailWikicodeForGANPage(reviewWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	getFailWikicodeForTalkPage(talkWikicode, reviewTitle, oldid) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		// Deleting {{GA nominee}} from article talk page.
		let topic = this.getTopicFromGANomineeTemplate(talkWikicode);
		let 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
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		return this.changeGANomineeTemplateStatus(talkWikicode, 'onhold');
	}

	getAskSecondOpinionWikicodeForTalkPage(talkWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		return this.changeGANomineeTemplateStatus(talkWikicode, '2ndopinion');
	}

	getAnswerSecondOpinionWikicodeForTalkPage(talkWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		return this.changeGANomineeTemplateStatus(talkWikicode, 'onreview');
	}

	findSectionStartAndEnd(gaSubpageHeading, gaSubpageWikicode, gaDisplayTitle) {
		// find heading
		let 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 ) {
			let endOfLine = this.findFirstStringAfterPosition('\n', gaSubpageWikicode, startOfLine);
			let line = gaSubpageWikicode.slice(startOfLine, endOfLine);
			let lineWithSomeFormattingRemoved = this.removeFormattingThatInterferesWithSort(line);
			let 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
		let regex = new RegExp(`({{GA nominee[^\\}]*\\|\\s*status\\s*=\\s*${newStatus})`, 'i');
		let alreadyHasCorrectStatus = talkWikicode.match(regex);
		if ( alreadyHasCorrectStatus ) {
			return talkWikicode;
		}

		// has a status, but needs to be changed
		let 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) {
		if ( arguments.length !== 7 ) throw new Error('Incorrect # of arguments');

		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 ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

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

		// place top piece after first H2, if it exists
		let prependText =
`{{atop${colorCode}
| status = 
| result = ${result}
}}`;
		let 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
		let appendText = `{{abot}}`;
		wikicode = wikicode.trim();
		wikicode += `\n${appendText}\n`;

		return wikicode;
	}

	getTopicFromGANomineeTemplate(talkWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	getTemplateParameter(wikicode, templateName, parameterName) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		templateName = this.regExEscape(templateName);
		parameterName = this.regExEscape(parameterName);
		let regex = new RegExp(`\\{\\{${templateName}[^\\}]+\\|\\s*${parameterName}\\s*=\\s*([^\\}\\|]+)\\s*[^\\}]*\\}\\}`, 'i');
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
	}

	deleteGANomineeTemplate(talkWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	addGATemplate(talkWikicode, topic, gaPageNumber, oldid) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');

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

	addFailedGATemplate(talkWikicode, topic, gaPageNumber, oldid) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');

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

	addTemplateInCorrectMOSTalkOrderPosition(talkWikicode, codeToAdd) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		let 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[]} templates
	 */
	addWikicodeAfterTemplates(wikicode, templates, codeToAdd) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

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

	/**
	 * @param {RegExp} regex /g flag must be set
	 * @returns {number} endOfStringPosition Returns zero if not found
	 */
	getEndOfStringPositionOfLastMatch(haystack, regex) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		let matches = [...haystack.matchAll(regex)];
		let hasMatches = matches.length;
		if ( hasMatches ) {
			let lastMatch = matches[matches.length - 1];
			let lastMatchStartPosition = lastMatch['index'];
			let lastMatchStringLength = lastMatch[0].length;
			let lastMatchEndPosition = lastMatchStartPosition + lastMatchStringLength;
			return lastMatchEndPosition;
		}
		return 0;
	}

	changeWikiProjectArticleClassToGA(talkWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		// 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		let i = 1;
		while ( true ) {
			let regex = new RegExp(`\\|\\s*action${i}\\s*=`, 'i');
			let hasAction = talkWikicode.match(regex);
			if ( ! hasAction ) {
				return i;
			}
			i++;
		}
	}

	updateArticleHistory(talkWikicode, topic, nominationPageTitle, listedOrFailed, oldid) {
		if ( arguments.length !== 5 ) throw new Error('Incorrect # of arguments');

		let 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');
		let topicString = `\n|topic = ${topic}`;

		// https://en.wikipedia.org/wiki/Template:Article_history#How_to_use_in_practice
		let existingStatus = this.firstTemplateGetParameterValue(talkWikicode, 'Artricle history', 'currentstatus')
		talkWikicode = this.firstTemplateDeleteParameter(talkWikicode, 'Article ?history', 'currentstatus');
		let 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 ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

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

	firstTemplateGetParameterValue(wikicode, template, parameter) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

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

	/**
	 * @param {RegExp} regex
	 */
	preg_position(regex, haystack) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		let matches = [...haystack.matchAll(regex)];
		let 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;
		for ( let i = templateStartPosition + 1 /* +1 to skip the first {{, will throw off our nesting count */; i < wikicode.length; i++ ) {
			let 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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		// templateStartPosition
		let regex = new RegExp('\{\{' + templateRegEx, 'gi');
		let templateStartPosition = this.preg_position(regex, wikicode);

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

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

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

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

		return wikicode;
	}

	removeFormattingThatInterferesWithSort(str) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

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

		let arr1 = [a, b];
		let 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
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		var 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(var i=0; i<defaultDiacriticsRemovalMap.length; i++) {
			str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base);
		}

		return str;
  	}

	getGASubpageHeadingPosition(shortenedVersionInComboBox, wikicode) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		// split the heading into equalsSignOnOneSide + needle + equalsSignOnOneSide
		let needle = /^={2,5}\s*(.*?)\s*={2,5}$/gm.exec(shortenedVersionInComboBox)[1];
		let 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
		let regex = new RegExp(`^${equalsSignsOnOneSide}\\s*(?:\\[\\[File:[^\\]]*\\]\\]\\s*)?${this.regExEscape(needle)}\\s*(?:<!--[^\\-]*-->)?\\s*${equalsSignsOnOneSide}$`, 'gm');
		let result = regex.exec(wikicode);

		let 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 {
			let headingPosition = result.index;
			return headingPosition;
		}
	}

	findFirstStringAfterPosition(needle, haystack, position) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		let len = haystack.length;
		for ( let i = position; i < len; i++ ) {
			let 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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		return [
			bigString.slice(0, position),
			insertString,
			bigString.slice(position)
		].join('');
	}

	hasArticleHistoryTemplate(wikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

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


class GARCloserController {
	/**
	 * @param {function} $ 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) {
		if ( arguments.length !== 5 ) throw new Error('Incorrect # of arguments');

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

		let hasGARLinkTemplate = await this.hasGARLinkTemplate(this.talkPageTitle);
		let 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		// 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

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

	async hasATOP(title) {
		let 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

	async processKeepForGARPage() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		this.pushStatus(`Remove {{GAR/link}} from talk page, and update {{Article history}}`);
		let wikicode = await this.getWikicode(this.talkPageTitle);
		let 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 ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

	async makeCommunityAssessmentLogEntry() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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);
		let garTemplateCount = this.countGARTemplates(archiveOldWikicode);

		// do we need to start a new archive page?
		let 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
		let 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

		// read the wikicode for Category:GAR/#
		let categoryTitle = `Category:GAR/${archiveNumber}`;
		let 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 ) {
			let newWikicode =
`{{Wikipedia category}}

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

	async incrementGARArchiveTemplate(archiveTitle) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		this.pushStatus(`Update count at Template:GARarchive`);
		let wikicode = await this.getWikicode('Template:GARarchive');
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		let number = title.match(/\d{1,}$/);
		number++;
		let titleWithNoNumber = title.replace(/\d{1,}$/, '');
		return titleWithNoNumber + number.toString();
	}

	/**
	 * Counts number of times "{{Wikipedia:Good article reassessment/" occurs in wikicode.
	 */
	countGARTemplates(wikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		return (haystack.match(needleRegEx)||[]).length;
	}

	/**
	 * @param {'keep'|'delist'} keepOrDelist
	 */
	async makeScriptLogEntry(keepOrDelist) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		this.pushStatus(`Add entry to GARCloser debug log`);
		let username = this.mw.config.get('wgUserName');
		let 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

		let 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

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

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

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

		let couldBeIndividualReassessment = isTalkNamespace && isGASubPage && hasGAReassessmentHeading;

		if ( couldBeIndividualReassessment ) {
			parentArticle = this.getIndividualReassessmentParentArticle(this.garPageTitle);
			let 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

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

	getIndividualReassessmentParentArticle(title) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	getCommunityReassessmentParentArticle(title) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	async getWikicode(title) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	async getWikicodeAndDoNotThrowError(title) {
		try {
			return await this.getWikicode(title);
		} catch (err) {

		}

		return '';
	}

	async makeEdit(title, editSummary, wikicode) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		if ( this.apiMode ) {
			// 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;
	}

	/**
	  * Lets you append without getting the Wikicode first. Saves an API query.
	  * @private
	  */
	async appendToPage(title, editSummary, wikicodeToAppend) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		if ( this.apiMode ) {
			// 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,
			"appendtext": wikicodeToAppend,
			"summary": editSummary,
		};
		let result = await api.postWithToken('csrf', params);
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		let t = new this.mw.Title(prefix); // https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Title
		let prefixNoNamespace = t.getMainText();
		let namespaceNumber = t.getNamespaceId();
		let api = new this.mw.Api();
		let params ={
			"action": "query",
			"format": "json",
			"list": "allpages",
			"apprefix": prefixNoNamespace,
			"apnamespace": namespaceNumber,
			"aplimit": "1",
			"apdir": "descending"
		};
		let result = await api.post(params);
		let title = result['query']['allpages'][0]['title'];
		return title;
	}

	pushStatus(statusToAdd) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		// don't run when not viewing articles
		let action = this.mw.config.get('wgAction');
		if ( action != 'view' ) return false;
		
		// don't run when viewing diffs
		let isDiff = this.mw.config.get('wgDiffNewId');
		if ( isDiff ) return false;
		
		let 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

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

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

class GARCloserHTMLGenerator {
	getHTML() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');
		return this.processGARPage(garPageWikicode, message, isCommunityAssessment, 'Kept.', 'green');
	}

	processKeepForTalkPage(wikicode, garPageTitle, talkPageTitle, oldid) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');
		return this.processGARPage(garPageWikicode, message, isCommunityAssessment, 'Delisted.', 'red');
	}

	processDelistForTalkPage(wikicode, garPageTitle, talkPageTitle, oldid) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 5 ) throw new Error('Incorrect # of arguments');
		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 ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		if ( ! message.includes('~~~~') ) {
			message += ' ~~~~';
		}
		return message;
	}

	setMessageIfEmpty(defaultText, message) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		if ( message === '' ) {
			message = defaultText;
		}
		return message;
	}

	getMessageForAtop(isCommunityAssessment, message) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		message = message.replace(/ ?~~~~/g, '');
		return wikicode.replace(/\{\{GAR\/current\}\}/i, `{{subst:GAR/result|result=${this.escapeTemplateParameter(message)}}} ~~~~`);
	}

	escapeTemplateParameter(parameter) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		// 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		return parseInt(title.match(/\d{1,}$/));
	}

	placeATOP(wikicode, result, color) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
	}

	removeTemplate(templateName, wikicode) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		let regex = new RegExp(`\\{\\{${this.regExEscape(templateName)}[^\\}]*\\}\\}\\n?`, 'i');
		return wikicode.replace(regex, '');
	}

	regexGetFirstMatchString(regex, haystack) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		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 ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		let matches = [...haystack.matchAll(regex)];
		let hasMatches = matches.length;
		if ( hasMatches ) {
			return matches[0]['index'];
		}
		return false;
	}

	deleteMiddleOfString(string, deleteStartPosition, deleteEndPosition) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 4 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		// 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 ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		if ( keepOrDelist === 'keep' ) {
			return `\n|currentstatus = ${existingStatus}`;
		} else {
			return '\n|currentstatus = DGA';
		}
	}

	/**
	 * @param {Array} templateNameArrayCaseInsensitive
	 */
	firstTemplateInsertCode(wikicode, templateNameArrayCaseInsensitive, codeToInsert) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 2 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		return wikicode.replace(/(\|\s*class\s*=\s*)([^\}\|\s]*)/gi, '$1');
	}

	firstTemplateDeleteParameter(wikicode, template, parameter) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');
		// 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 {function} $ jQuery
	 * @param {mw} mw mediawiki, https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw
	 * @param {MassGARWikicodeGenerator} wg
	 * @param {GARCloserController} gcc
	 * @param {GARCloserWikicodeGenerator} gcwg
	 */
	async execute($, mw, mgwg, gcc, gcwg) {
		if ( arguments.length !== 5 ) throw new Error('Incorrect # of arguments');

		// 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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

		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() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

	isAuthorizedUser() {
		if ( arguments.length !== 0 ) throw new Error('Incorrect # of arguments');

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

	pushStatus(statusToAdd) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

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

	async getWikicode(title) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		// 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) {
		if ( arguments.length !== 3 ) throw new Error('Incorrect # of arguments');

		// 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		let gaTemplateNames = ['ga icon', 'ga article', 'good article'];
		return this._wikicodeHasTemplate(mainArticleWikicode, gaTemplateNames);
	}

	talkPageIndicatesGA(talkPageWikicode) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');

		// 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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		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) {
		if ( arguments.length !== 1 ) throw new Error('Incorrect # of arguments');
		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>