Jump to content

User:Ahecht/sandbox/Scripts/pageswap-core.js

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

function pageSwap(prefix, moveReason, debug) {
	var config = {
		link: "using [[" + prefix + "|Pageswap GUI]])",
		intermediatePrefix: "Draft:Move/",
		portletLink: "Swap (sandbox)" + (debug ? " (debug)" : ""),
		portletAlt: "Perform a revision history swap / round-robin move",
		validateButton: 'Validate page swap (sandbox)' + (debug ? " (debug)" : ""),
		validatingButton: 'Validating page swap (sandbox)' + (debug ? " (debug)" : ""),
		confirmButton: 'Confirm (sandbox)' + (debug ? " (debug)" : ""),
		confirmMessageHeader: "'''Round-robin configuration:'''\n*",
		confirmMessageFooter: '\nPress "Confirm" to proceed.',
		statusMessageHeader: "'''Performing page swap:'''\n",
		introText: "<big>'''Please post bug reports/comments/suggestions for " +
			'the Pageswap GUI script at [[User talk:Ahecht]]. To revert to the ' +
			'previous dialogue-based version of this script, use ' +
			"[[User:Ahecht/Scripts/pageswap_1.5.2.js]] instead.'''</big>\n\n" +
			'Using the form below will [[Wikipedia:Moving a page#Swapping ' +
			'two pages|swap]] two pages using the [[User:Ahecht/Scripts/' +
			'pageswap|Pageswap GUI]] script, moving all of their histories to ' +
			"the new names. '''Links to the old page titles will not be " +
			"changed'''. Be sure to check '''[[Special:MyContributions]]''' " +
			'for [[Special:DoubleRedirects|double]] or [[Special:' +
			'BrokenRedirects|broken redirects]] and [[Wikipedia:Red link|red ' +
			'links]]. You are responsible for making sure that links continue' +
			' to point where they are supposed to go and for doing all post-' +
			'move cleanup listed under [[User:Ahecht/Scripts/pageswap' +
			'#Out of scope|Out of scope]] in the script\'s documentation.\n\n' +
			"'''Note:''' This can be a drastic and unexpected change for a " +
			'popular page; please be sure you understand the consequences of ' +
			'this before proceeding. Please read [[Wikipedia:Moving a page]] ' +
			'for more detailed instructions.',
		doneMsgCleanup: 'Please do post-move cleanup as necessary',
		doneMsgRedlink: 'create new red-linked talk pages/subpages if ' +
			'there are incoming links (check your [[Special:MyContributions|' +
			'contribs]] for "Talk:" and subpage redlinks)',
		doneMsgRedir: 'correct any moved redirects (including on talk pages ' +
			'and subpages)',
		doneSubpages: '*The following subpage(s) were moved, and may need new ' +
			'or updated redirects:\n',
		errorMsg: 'Error adding swap form to page!',
		rrReason: ' ([[WP:Page mover#rr|Round-robin swap]] step 1 ',
		newRedirMsg: 'The following redirect(s) will be created or modified '+
			'([[#rcat|choose redirect categories]]):',
		rcatShell: '{{Redirect category shell|\n$1\n}}',
		rcatDefault: '{{R from move}}',
		rcatChoose: 'Choose redirect categories for the newly created redirects:',
		rcatsAdded: '* The following redirect categories will be added where possible: ',
		rcatCat: 'Category:Redirect templates',
		rcatTempNSRegEx: new RegExp("\\|\\s*(\\S*?) category\\s*=", "g"),
		types: ['notice', 'success', 'warning', 'error'],
	}, params = {
		apiData: {}, currTitle: {}, destTitle: {},
		confirmMessages: [], statusMessages: [], selfRedirs: [], rcats: [],
		selectedRcats: { [config.rcatDefault]: ["all"] },
		defaultMoveTalk: true, confDone: false, editRedir: false, done: false,
		busy: 0, idempotency: {psConfirm: 0, psStatus: 0},
		cleanup: (
			typeof pagemoveDoPostMoveCleanup === 'undefined' ?
			true : 
			pagemoveDoPostMoveCleanup
		)
	};
	
	function filterHtml(rawHtml) {
		$value=$($.parseHTML(rawHtml));
		$value.filter("div.mw-parser-output").contents().each(function() {
			if(this.nodeType === Node.COMMENT_NODE || this.nodeType === Node.TEXT_NODE) {
				$(this).remove();
			}
		}).find('a.mw-redirect').each(function() {
			$(this).attr('href', $(this).attr('href') + "?redirect=no");
		});
		return $value.html();
	}
	
	function setLabel(container, label, type, idempotency) {
		if (config.types.indexOf(type) > config.types.indexOf(container.type)) {
			container.setType(type);
		}
		label = new OO.ui.HtmlSnippet(label);
		if (idempotency == params.idempotency[container.elementId]) {
			container.setLabel(label).toggle(true).scrollElementIntoView().always( () => {
				$( 'a[href="#rcat"]' ).off('click').on('click', (e) => {
					e.preventDefault();
					mw.loader.load('https://tools-static.wmflabs.org/cdnjs/ajax/libs/select2/4.0.13/css/select2.min.css', 'text/css');
					mw.loader.getScript('https://tools-static.wmflabs.org/cdnjs/ajax/libs/select2/4.0.13/js/select2.min.js').then( () => {
						if (params.rcats.length == 0) {
							getRcats();
						} else {
							showRcatDialog();
						}
					} );
					return false;
				});
				if (psContribsButton.isVisible() && !psContribsButton.isDisabled()) {
					psContribsButton.scrollElementIntoView();
				} else if (psButton.isVisible() && !psButton.isDisabled()) {
					psButton.scrollElementIntoView();
				}
			} );
		}
	}
	
	function parseError(ps, label, codetr, reslttr, idempotency) {
			label = "Error parsing wikitext:\n\n" + label + "\n\n" +
				(reslttr.error.info || (codetr + "."));
			console.warn(label);
			setLabel(ps, label, 'error', idempotency);
		}
	
	function showConfirm(message, type='notice', done=false) {
		if (done) params.confDone = true;
		
		var idempotency = ++params.idempotency.psConfirm;

		if (message !== '') {
			params.confirmMessages.push(message.replace("[[WP:RM/TR]]",
				"[[WP:Requested moves/Technical requests|WP:RM/TR]]"));
		}
		
		var label = config.confirmMessageHeader +
			params.confirmMessages.join("\n*") +
			(params.confDone ? config.confirmMessageFooter : '');
		
		new mw.Api().parse(label).done(	(parsedText) => {
			setLabel(psConfirm, filterHtml(parsedText), type, idempotency);
		} ).fail( (codetr, reslttr) => 
			parseError(psConfirm, label, codetr, reslttr, idempotency)
		);
		
		if (type=='error') psProgress.toggle(false);
	}
	
	function showStatus(message, type='notice', done=false, topic=false) {
		var idempotency = ++params.idempotency.psStatus;

		if (done) params.done = true;
		
		if (message !== '') {
			var topicFlag = topic ? "<!--"+topic+"-->" : false;
			var topicIndex = params.statusMessages.findIndex((str) => str.indexOf(topicFlag) > -1);
			message = "*" + message.replace("[[WP:RM/TR]]",
				"[[WP:Requested moves/Technical requests|WP:RM/TR]]") + "\n" +
				(topicFlag || "");
			if (topicIndex > -1) {
				params.statusMessages[topicIndex] = params.statusMessages[topicIndex].replace(topicFlag, message);
			} else {
				params.statusMessages.push(message);
			}
		}

		var doneSubpagesMessage = "", doneMessage = "";
		if (params.done && params.busy == 0) {
			if (params.allSpArr.length) doneSubpagesMessage = config.doneSubpages + "**[[" + 
				params.allSpArr.join("]]\n**[[") + "]]\n";
			
			psContribsButton.toggle(true);
			
			var doneMessages = [config.doneMsgCleanup];
			if (!params.talkRedirect || params.moveSubpages) doneMessages.push(config.doneMsgRedlink);
			if (!params.fixSelfRedirect || params.moveSubpages) doneMessages.push(config.doneMsgRedir);
			
			if (doneMessages.length < 3) {
				doneMessage = doneMessages.join(" and ") + ".";
			} else {
				doneMessage = doneMessages.slice(0, -1).join(', ') + ', and ' +
					doneMessages.slice(-1) + ".";
			}
			type = 'success';
		}
		
		var label = config.statusMessageHeader + params.statusMessages.join('') +
			doneSubpagesMessage + doneMessage;
		
		new mw.Api().parse(label).done(
			(parsedText) => setLabel(psStatus, filterHtml(parsedText), type, idempotency)
		).fail(
			(codetr, reslttr) => parseError(psStatus, label, codetr, reslttr, idempotency)
		);
	}
	
	function parsePagesData() {
		// get page data, normalize titles
		var ret = {valid: true, invalidReason: ''};
		var query = params.apiData;
		if (typeof query.pages !== 'undefined' && typeof query.logevents !== 'undefined') {
			for (var kn in query.normalized) {
				var qn = query.normalized[kn];
				if (params.currTitle.title == qn.from) {
					params.currTitle.title = qn.to;
				} else if (params.destTitle.title == qn.from) {
					params.destTitle.title = qn.to;
				}
			}
			for (var kp in query.pages) {
				var qp = query.pages[kp];
				if ([params.currTitle.title,params.destTitle.title].includes(qp.title)) {
					if (params.currTitle.title == qp.title) {
						params.currTitle = qp;
					} else if (params.destTitle.title == qp.title) {
						params.destTitle = qp;
					}
					if (kp < 0) {
						ret.valid = false;
						if (typeof qp.missing !== 'undefined') {
							ret.invalidReason += "Unable to find [["+qp.title+"]]. ";
						} else if (typeof qp.invalid !== 'undefined' &&
							typeof qp.invalidreason !== 'undefined') {
							ret.invalidReason += qp.invalidreason;
						} else {
							ret.invalidReason += "Unable to get page data for"+params.titlesString;
						}
					}
				}
			}
			for (var kl in query.logevents) {
				var lastMove = (Date.now()-Date.parse(query.logevents[kl].timestamp))/(1000*60);
				if ( lastMove < 60 ) { // 1 hour
					showConfirm("<b>Warning: [[" + params.currTitle.title + "]] was moved " +
						Math.round(lastMove) + " minute(s) ago.</b>",
						'warning');
				} else if ( lastMove < 1440 ) { // 1 day
					showConfirm("<b>Note: [[" + params.currTitle.title + "]] was moved " +
						Math.round(lastMove/60) + " hour(s) ago.</b>",
						'notice');
				} else if ( lastMove < 43200 ) { // 30 days
					showConfirm("[[" + params.currTitle.title + "]] was last moved " +
						Math.round(lastMove/1440) + " day(s) ago.</b>",
						'notice');
				}
			}
		} else {
			ret = {valid: false, invalidReason: "Unable to get page data for"+params.titlesString};
		}

		return ret;
	}
			
	/**
	 * Given two (normalized) titles, find their namespaces, if they are redirects,
	 * if have a talk page, whether the current user can move the pages, suggests
	 * whether movesubpages should be allowed, whether talk pages need to be checked
	 */
	function swapValidate(ret) {
		 // get page data, normalize titles
		if (ret.valid === false || params === null ||
			params.currTitle.title === null || params.destTitle.title === null
		) {
			ret.valid = false;
			ret.invalidReason += "Failed to validate swap.";
			return ret;
		}

		ret.allowMoveSubpages = true;
		ret.checkTalk = true;
		for (const k of ["currTitle", "destTitle"]) {
			if (k == "-1" || params[k].ns < 0) {
				ret.valid = false;
				ret.invalidReason = ("Page " + params[k].title + " does not exist.");
				return ret;
			}
			// enable only in ns 0..5,12,13,118,119 (Main,Talk,U,UT,WP,WT,H,HT,D,DT)
			if ((params[k].ns >= 6 && params[k].ns <= 9) ||
			 (params[k].ns >= 10 && params[k].ns <= 11 && !params.uPerms.allowSwapTemplates) ||
			 (params[k].ns >= 14 && params[k].ns <= 117) ||
			 (params[k].ns >= 120)) {
				ret.valid = false;
				ret.invalidReason = ("Namespace of " + params[k].title + " (" +
					params[k].ns + ") not supported.\n\nLikely reasons:\n" +
					"- Names of pages in this namespace relies on other pages\n" +
					"- Namespace features heavily-transcluded pages\n" +
					"- Namespace involves subpages: swaps produce many redlinks\n" +
					"\n\nIf the move is legitimate, consider a careful manual swap.");
				return ret;
			}
			ret[k]                      = params[k].title;
			ret[k.slice(0,4)+"Ns"]      = params[k].ns;
			ret[k.slice(0,4)+"CanMove"] = params[k].actions.move === '';
			ret[k.slice(0,4)+"IsRedir"] = params[k].redirect === '';
		}

		if (!ret.valid) return ret;
		if (!ret.currCanMove) {
			ret.valid = false;
			ret.invalidReason = ('' + ret.currTitle + " is immovable. Aborting");
			return ret;
		}
		if (!ret.destCanMove) {
			ret.valid = false;
			ret.invalidReason = ('' + ret.destTitle + " is immovable. Aborting");
			return ret;
		}
		if (ret.currNs % 2 !== ret.destNs % 2) {
			ret.valid = false;
			ret.invalidReason = "Namespaces don't match: one is a talk page.";
			return ret;
		}
		ret.currNsAllowSubpages = params.apiData.namespaces['' + ret.currNs].subpages !== '';
		ret.destNsAllowSubpages = params.apiData.namespaces['' + ret.destNs].subpages !== '';

		// if same namespace (subpages allowed), if one is subpage of another,
		// disallow movesubpages
		if (ret.currTitle.startsWith(ret.destTitle + '/') ||
				ret.destTitle.startsWith(ret.currTitle + '/')) {
			if (ret.currNs !== ret.destNs) {
				ret.valid = false;
				ret.invalidReason = "Strange.\n" + ret.currTitle + " in ns " +
					ret.currNs + "\n" + ret.destTitle + " in ns " + ret.destNs +
					". Disallowing.";
				return ret;
			}

			ret.allowMoveSubpages = ret.currNsAllowSubpages;
			if (!ret.allowMoveSubpages)
				ret.addlInfo = "One page is a subpage. Disallowing move-subpages";
		}

		if (ret.currNs % 2 === 1) {
			ret.checkTalk = false; // no need to check talks, already talk pages
		} else { // ret.checkTalk = true;
			ret.currTitleWithoutPrefix = mw.Title.newFromText( ret.currTitle ).title;
			ret.currTalkName = mw.Title.newFromText( ret.currTitle ).getTalkPage().getPrefixedText();
			ret.destTitleWithoutPrefix = mw.Title.newFromText( ret.destTitle ).title;
			ret.destTalkName = mw.Title.newFromText( ret.destTitle ).getTalkPage().getPrefixedText();
		}
		return ret;
	}

	/**
	 * Given two talk page titles (may be undefined), retrieves their pages for comparison
	 * Assumes that talk pages always have subpages enabled.
	 * Assumes that pages are not identical (subject pages were already verified)
	 * Assumes namespaces are okay (subject pages already checked)
	 * (Currently) assumes that the malicious case of subject pages
	 *   not detected as subpages and the talk pages ARE subpages
	 *   (i.e. A and A/B vs. Talk:A and Talk:A/B) does not happen / does not handle
	 * Returns structure indicating whether move talk should be allowed
	 */
	function talkValidate(checkTalk, talk1, talk2) {
		var ret = {allowMoveTalk: true};
		if (!checkTalk) return ret; // currTitle destTitle already talk pages
		if (talk1 === undefined || talk2 === undefined) ret.allowMoveTalk = false;
		ret.currTDNE = true;
		ret.destTDNE = true;
		ret.currTCanCreate = true;
		ret.destTCanCreate = true;
		var talkTitleArr = [talk1, talk2];
		if (talkTitleArr.length !== 0 && typeof params.apiData?.pages !== 'undefined') {
			var talkData = params.apiData.pages;
			for (var id in talkData) {
				if (talkData[id].title === talk1) {
					ret.currTDNE = talkData[id].invalid === '' || talkData[id].missing === '';
					ret.currTTitle = talkData[id].title;
					ret.currTCanMove = talkData[id].actions.move === '';
					ret.currTCanCreate = talkData[id].actions.create === '';
					ret.currTalkIsRedir = talkData[id].redirect === '';
				} else if (talkData[id].title === talk2) {
					ret.destTDNE = talkData[id].invalid === '' || talkData[id].missing === '';
					ret.destTTitle = talkData[id].title;
					ret.destTCanMove = talkData[id].actions.move === '';
					ret.destTCanCreate = talkData[id].actions.create === '';
					ret.destTalkIsRedir = talkData[id].redirect === '';
				}
			}
		} else {
			ret.allowMoveTalk = false;
		}
		
		if (!ret.allowMoveTalk) {
			showStatus("Unable to validate talk. Disallowing movetalk to be safe.", 'warning');
		} else {
			ret.allowMoveTalk = (ret.currTCanCreate && ret.currTCanMove) &&
				(ret.destTCanCreate && ret.destTCanMove);
		}
		
		if (params.moveTalk && params.talkRedirect) {
			if (ret.currTDNE && !ret.destTDNE) {
				ret.redirFromTalk = talk2;
				ret.redirToTalk = talk1;
			} else if (ret.destTDNE && !ret.currTDNE) {
				ret.redirFromTalk = talk1;
				ret.redirToTalk = talk2;
			}
		}
		
		return ret;
	}

	/**
	 * Given existing title (not prefixed with "/"), optionally searching for talk,
	 *   finds subpages (incl. those that are redirs) and whether limits are exceeded
	 */
	function getSubpages(title, isTalk) {
		var deferred = $.Deferred();
		var titleObj = isTalk ? mw.Title.newFromText( title ).getTalkPage() :
			mw.Title.newFromText( title );
		var nsSubpages = params.apiData.namespaces['' + titleObj.namespace].subpages;
		if ((!titleObj.isTalkPage()) && nsSubpages !== '') {
			deferred.resolve( [] );
		} else {
			var queryData = { format:'json', action:'query',
				prop:'info', intestactions:'move|create',
				generator:'allpages', gapprefix:titleObj.title + '/',
				gapnamespace:titleObj.namespace, gaplimit:101,
			};
			new mw.Api().get(queryData).done( (subpages) => {
				if ( typeof subpages !== 'object' ) {
					deferred.reject( "API did not return data for subpages of "+title+". Subpages may exist." );
				} else if (typeof subpages?.query?.pages === 'undefined') {
					if (subpages.batchcomplete === '') { //no subpages found
						deferred.resolve( [] );
					} else { //something else went wrong
						console.warn("API did not return 'pages' when querying subpage data:");console.log(subpages);
						deferred.reject( "API did not return subpage data for "+title+". Subpages may exist." );
					}
				} else if (Object.keys(subpages.query.pages).length > 101) {
					deferred.reject( "100+ subpages of "+title+". Aborting" );
				} else {
					subpages = subpages.query.pages;
					var dataret = [];
					for (var k in subpages) {
						dataret.push( {
							title:subpages[k].title,
							isRedir:subpages[k].redirect === '',
							canMove:subpages[k].actions.move === ''
						} );
					}
					deferred.resolve( dataret );
				}
			} ).fail( (jqXHR, textStatus, errorThrown) => {
				var errStr = "API error '"+(jqXHR.status||textStatus)+
					"' when searching for subpages of "+title+". "+
					(errorThrown||jqXHR.responseText).replace("\n","");
				console.warn(errStr);console.log(queryData);console.log(jqXHR);
				deferred.reject( errStr+" Subpages may exist." );
			} );
		}
		return deferred.promise();
	}

	/**
	 * Prints subpage data given retrieved subpage information returned by getSubpages
	 * Returns a suggestion whether movesubpages should be allowed
	 */
	function printSubpageInfo(basepage, currSp) {
		var ret = {};
		var currSpArr = [];
		var currSpCannotMove = [];
		var redirCount = 0;
		for (var kcs in currSp) {
			if (!currSp[kcs].canMove) currSpCannotMove.push(currSp[kcs].title);
			currSpArr.push(currSp[kcs].title);
			if (currSp[kcs].isRedir) redirCount++;
		}

		if (params.moveSubpages) {
			if (currSpArr.length > 0) {
				if (currSpCannotMove.length > 0) {
					showConfirm("Disabling move-subpages." +
						"The following " + currSpCannotMove.length + " (of " +
						currSpArr.length + ") total subpage(s) of [[" +
						basepage + "]] CANNOT be moved:\n**[[" +
						currSpCannotMove.join("]]\n**[[") + "]]",
						'warning');
				} else if (typeof basepage !== 'undefined') {
					showConfirm(currSpArr.length + " total subpages of [[" + basepage + "]]" +
						(redirCount !== 0 ? (" (" + redirCount + " redirects):") : ":") +
						"\n**[[" + currSpArr.join("]]\n**[[") + "]]");
				}
			}
		}

		ret.allowMoveSubpages = currSpCannotMove.length === 0;
		ret.noNeed = currSpArr.length === 0;
		ret.spArr = currSpArr;
		return ret;
	}
	
	var filterRcats = (ns) => ( Object.keys(params.selectedRcats).filter( 
		(e) => ( params.selectedRcats[e].some(
			(v) => (v == 'all' || v == 'other' || v == 'unknown' || v == ns)
		) )
	) );
	
	function createMissingTalk(vData, vTData) {
		var fromTalk = vTData.redirFromTalk, toTalk = vTData.redirToTalk;
		if (fromTalk && toTalk) {
			params.busy++;
			setTimeout( () => {
				var talkRedirect = {
					action:'edit',
					title:fromTalk,
					createonly: true,
					text: "#REDIRECT [[" + toTalk + "]]\n\n" +
						config.rcatShell.replace( '$1', filterRcats('talk').join('\n') ),
					summary: "Create redirect to [[" + toTalk + "]] (" + config.link,
					watchlist: params.watch
				};
				showStatus("Creating talk page redirect [[" + fromTalk +
					"]] → [[" + toTalk + "]]...",'notice', false,
					"TPR" + fromTalk);
				if (debug) {
					params.busy--;
					showStatus("* Talk page redirect simulated!.",
						'notice', true, "TPR" + fromTalk);
				} else {
					new mw.Api().postWithEditToken(talkRedirect).done( () => {
						params.busy--;
						showStatus("* Talk page redirect created!",
							'notice', true, "TPR" + fromTalk);
					} ).fail( (codetr, reslttr) => {
						params.busy--;
						showStatus("* Failed to create redirect! " + 
							(reslttr.error.info || (codetr + ".")),
							'error', true, "TPR" + fromTalk);
					} );
				}
			}, 250);
		} else { showStatus('', 'notice', true); }
	}

	function retargetRedirect(thisPage, otherPage, newText) {
		params.busy++;
		showStatus("Retargeting redirect at [[" + thisPage +
			"]] to [["	+ otherPage + "]]...",
			'notice', false, "RT"+thisPage);
		var retargetData = {
				action:'edit',
				title: thisPage,
				text: newText,
				summary : "Retarget redirect to [[" + otherPage + "]] (" +
					config.link,
				watchlist: params.watch
			};
		if (debug) {
			params.busy--;
			showStatus("* Retargeting simulated!",'notice', false, "RT"+thisPage);
		} else {
			new mw.Api().postWithEditToken(retargetData).done( (result, jqXHR) => {
				params.busy--;
				if (typeof result.edit !== 'undefined') {
					params.busy++;
					new mw.Api().get( {
						action: 'query', prop: '', redirects: '',
						titles: result.edit.title
					} ).done( (data) => {
						params.busy--;
						if (data && typeof data?.query?.redirects !== 'undefined') {
							showStatus("* Redirect retargeted!", 'notice',
								false, "RT"+thisPage);
						} else {
							console.warn("Error parsing redirects after retargeting:");
							console.warn(data);
						}
					} ).fail( (codeart, rsltart) => {
						params.busy--;
						console.warn("Error fetching page after retargeting:");
						console.warn(codeart);console.warn(rsltart);
					} );
				} else {
					console.warn("Error parsing result of retargeting:");
					console.warn(result);console.warn(jqXHR);
				}
			} ).fail( (codert, resultrt) => {
				params.busy--;
				showStatus("* Retargeting failed. "+
				(resultrt.error.info || (codert + ".")),
				'error', false, "RT"+thisPage);
			} );
		}
	}

	function preCheckSelfRedirs(vData) {
		var pagesArr = [vData.currTitle, vData.destTitle,
			vData.currTalkName,	vData.destTalkName];
		var redirs = params.apiData.redirects;
		for (const e in redirs) {
			var thisI = pagesArr.indexOf(redirs[e].from);
			if (thisI > -1) {
				var otherI = (thisI==0)?1:((thisI==1)?0:((thisI==2)?3:2));
				var otherPage = pagesArr[otherI];
				if(redirs[e].to == otherPage) params.selfRedirs.push(redirs[e].to);
			} else {
				showConfirm('Page ' + redirs[e].from + ' from redirects table not found in input data.', 'warning');
			}
		}
	}

	/**
	 * After successful page swap, post-move cleanup:
	 * Make talk page redirect
	 * TODO more reasonable cleanup/reporting as necessary
	 * vData.(curr|dest)IsRedir
	 */
	function checkSelfRedirs(vData, vTData) {
		var pagesArr = [vData.currTitle, vData.destTitle,
			vData.currTalkName,	vData.destTalkName];
		var srQuery = {
			action: "query", formatversion: "2", prop: "revisions|templates",
			titles: pagesArr.filter(
				(v) => params.selfRedirs.includes(v)
			).join('|'),
			rvprop: "content", rvslots: "main", rvsection: "",
			tlnamespace: "10", tllimit: "max"
		};
		params.busy++;
		new mw.Api().get( srQuery ).done( (queryData) => {
			params.busy--;
			if (queryData && queryData?.query?.pages?.[0]?.revisions[0] ) {
				queryData.query.pages.forEach( (pageData) => {
					var thisPage = pageData.title; 
					var thisI = pagesArr.indexOf(thisPage);
					var otherI = (thisI==0)?1:((thisI==1)?0:((thisI==2)?3:2));
					var otherPage = pagesArr[otherI];
					var oldText = pageData?.revisions?.[0]?.slots?.main.content;
					oldText = oldText ?? '';
					var redirRE = new RegExp(
						"^\\s*#REDIRECT\\s*\\[\\[ *.* *\\]\\]", "i"
					);
					if ((thisI > -1) && (oldText.search(redirRE) > -1)) {
						var pageRcats = [];
						if (pageData?.templates) {
							pageData.templates.forEach( (v) => {
								v = v.title;
								params.rcats.some( (e) => {
									if (e.id == v) return pageRcats.push(e.text), true;
								} );
							} );
						}
						var oldRcatL = pageRcats.length;
						pageRcats = pageRcats.concat( //combine and dedupe
							Object.keys(params.selectedRcats)
						).filter((v, i, a) => a.indexOf(v) === i);
						var thisNs = mw.Title.newFromText(thisPage).getNamespaceId();
						thisNs = (thisNs == 0) ? 'main' : ( (thisNs % 2 == 1) ? 'talk' :
							mw.config.get('wgFormattedNamespaces')[thisNs].toLowerCase() );
						var newText = "";
						if ( (pageRcats.length > 0) && (
							(oldText.search('{'+'{') == -1) ||
							(pageRcats.length != oldRcatL)
						) ) { // Completely replace redirect text
							newText = '#REDIRECT [['+otherPage+']]\n\n' + 
								config.rcatShell.replace('$1',
								filterRcats(thisNs).join('\n'));
						} else { // Just change target
							newText = oldText.replace(redirRE,
								'#REDIRECT [['+otherPage+']]');
						}
						retargetRedirect(thisPage, otherPage, newText);
					} else {
						showStatus("Attempt to retarget " +
							"redirect at [[" + thisPage + 
							"]] to [[" + otherPage + "]] " +
							"failed: String not found.",
							'warning');
					}
				} );
			} else {
				params.busy--;
				showStatus("Attempt to retarget redirect " +
					"at [[" + thisPage + "]] to [[" +
					otherPage + "]] failed: " +
					"Could not fetch contents.", 'error');
			}
		} ).fail( (jqXHR, textStatus) => {
			params.busy--;
			showStatus("Attempt to retarget redirect at [[" +
				pagesArr[i] + "]] failed due to API error '" + 
				(jqXHR.status||textStatus) + "' when " +
				"fetching page contents. ", 'error');
		} ).always( () => createMissingTalk(vData, vTData) );
	}

	/**
	 * Swaps the two pages (given all prerequisite checks)
	 * Optionally moves talk pages and subpages
	 */
	function swapPages(vData, vTData) {
		params.busy = 1;
		if (params.currTitle.title === null || params.destTitle.title === null ||
				params.moveReason === null || params.moveReason === '') {
			showStatus("Titles are null, or move reason given was empty. Swap not done", 'error');
			return false;
		}

		var currTitle = params.currTitle.title;
		var intermediateTitle = config.intermediatePrefix + currTitle;
		var destTitle = params.destTitle.title;
		
		if (debug) {
			showStatus("Simulating round-robin history swap...");
			showStatus("* Step 1 ([[" + destTitle + "]] → [[" + 
				intermediateTitle + "]])...");
			showStatus("* Step 2 ([[" + currTitle + "]] → [[" + 
				destTitle + "]])...");
			showStatus("* Step 3 ([[" + intermediateTitle + "]] → [[" + 
				currTitle + "]])...");
			var completeMessage = "* Round-robin history swap of [[" +
				currTitle + "]]&nbsp;([[Special:WhatLinksHere/" + 
				params.currTitle.title + "|links]]) and [[" +
				destTitle + "]]&nbsp;([[Special:WhatLinksHere/" + 
				params.destTitle.title + "|links]]) simulated successfully!";
			if (params.fixSelfRedirect || params.talkRedirect) {
				showStatus(completeMessage);
				params.busy--;
				if (params.fixSelfRedirect && params.selfRedirs.length > 0) {
					checkSelfRedirs(vData, vTData);
				} else {
					createMissingTalk(vData, vTData);
				}
			} else {
				params.busy--;
				showStatus(completeMessage, 'notice', true);
			}
		} else {
			showStatus("Doing round-robin history swap...");
			var mQuery = { action:'move', from:destTitle, to:intermediateTitle,
				reason:params.moveReason + config.rrReason + config.link,
				watchlist:params.watch, noredirect:1 };
			if (params.moveTalk) mQuery.movetalk = 1;
			if (params.moveSubpages) mQuery.movesubpages = 1;
			showStatus("* Step 1 ([[" + mQuery.from + "]] → [[" + 
				mQuery.to + "]])...");
			new mw.Api().postWithEditToken(mQuery).then( () => {
				Object.assign(mQuery, { from:currTitle, to:destTitle,
					reason: params.moveReason + " (" + config.link } );
				showStatus("* Step 2 ([[" + mQuery.from + "]] → [[" + 
					mQuery.to + "]])...");
				return new mw.Api().postWithEditToken(mQuery);
			} ).then( () => {
				Object.assign(mQuery, { from:intermediateTitle, to:currTitle,
					reason: params.moveReason + config.rrReason.slice(0,-2) +
						"3 " + config.link } );
				showStatus("* Step 3 ([[" + mQuery.from + "]] → [[" + 
					mQuery.to + "]])...");
				return new mw.Api().postWithEditToken(mQuery);
			} ).then( () => {
				var completeMessage = "* Round-robin history swap of [[" +
					currTitle + "]]&nbsp;([[Special:WhatLinksHere/" +
					currTitle + "|links]]) and [[" + destTitle + 
					"]]&nbsp;([[Special:WhatLinksHere/" + destTitle + 
					"|links]]) completed successfully!";
				if (params.fixSelfRedirect || params.talkRedirect) {
					showStatus(completeMessage);
					params.busy--;
					if (params.fixSelfRedirect && params.selfRedirs.length > 0) {
						checkSelfRedirs(vData, vTData);
					} else {
						createMissingTalk(vData, vTData);
					}
				} else {
					params.busy--;
					showStatus(completeMessage, 'notice', true);
				}
			} ).fail( (code, reslt) => {
				params.busy--;
				showStatus("* Failed when moving ([[" + mQuery.from + "]] → [[" +
					mquery.to + "]])! " + (reslt.error.info || (code + ".")),
					'error', true);
			} );
		}
	}
	
	/**
	 * Prompt for redirect categories for newly created redirects
 	*/
	function showRcatDialog() {
		var select = $( '<select>' ).attr( 'id', 'rcat-chooser-form' ).attr('multiple', 'multiple').append( 
			$( '<option>' ).attr( 'selected', 'selected' ).attr(
				'value', config.rcatDefault.replace(/\{\{(.*)\}\}/, "Template:$1")
			).text( config.rcatDefault )
		);
		var content = $( '<span>' ).append( '<p>' + config.rcatChoose + '</p>' ).append( select );

		// Subclass ProcessDialog.
		function ProcessDialog( config ) {
			ProcessDialog.super.call( this, config );
		}
		OO.inheritClass( ProcessDialog, OO.ui.ProcessDialog );
	
		ProcessDialog.static.name = 'rcatDialog';
		ProcessDialog.static.title = 'Select Redirect Categories';
		ProcessDialog.static.actions = [
			{
				action: 'save',
				label: 'Save',
				flags: [ 'primary', 'progressive' ] 
			},
			{
				label: 'Cancel',
				flags: [ 'safe', 'close' ]
			}
		];
	
		ProcessDialog.prototype.initialize = function () {
			ProcessDialog.super.prototype.initialize.apply( this, arguments );
			this.content = new OO.ui.PanelLayout( {
				padded: true,
				expanded: false
			} );
			this.content.$element.append( content );
			params.rcats.forEach( (v, i, a) => {a[i].selected = Object.keys(params.selectedRcats).includes(v.text);} );
			select.select2({data: params.rcats, width: '100%'}).on( 'change', () => {rcatDialog.updateSize();} );
			this.$body.append( this.content.$element );
		};
	
		ProcessDialog.prototype.getActionProcess = function ( action ) {
			if ( action ) {
				if (action == 'save') params.selectedRcats = {};
				if (action == 'save' && select.val().length > 0) {
					new mw.Api().get( {
						"action": "query", "prop": "revisions", "formatversion": 2,
						"titles": select.val().join('|'),
						"rvprop": "content", "rvslots": "main"
					} ).done( (data) => {
						if (data && data?.query?.pages?.[0]) {
							data.query.pages.forEach( (page) => {
								var pageContent = page?.revisions?.[0]?.slots?.main?.content;
								if (typeof pageContent === "string") {
									var tempCall = page.title.replace(/Template:(.*)/, '{'+'{$1}}');
									var nsMatches = Array.from(
										pageContent.matchAll(config.rcatTempNSRegEx),
										(v) => (v[1])
									);
									if (nsMatches.length == 0) nsMatches = ['unknown'];
									params.selectedRcats[tempCall] = nsMatches;
								}
							} );
						}
						if (Object.keys(params.selectedRcats).length > 0) {
							showConfirm(config.rcatsAdded + "<code><nowiki>" + 
								config.rcatShell.replace( 
									'$1', Object.keys(params.selectedRcats).join('\n')
								) +	"</nowiki></code>");
						}
					} ).fail( (jqXHR, textStatus) => {
						showConfirm("* API error '"+(jqXHR.status||textStatus)+
							"' when verifying Rcat templates.", 'error');
					} );
				}
				return new OO.ui.Process(
					() => this.close( {action: action} )
				);
			}
			return ProcessDialog.super.prototype.getActionProcess.call( this, action );
		};
	
		ProcessDialog.prototype.getBodyHeight = function () {
			return this.content.$element.outerHeight( true );
		};
	
		// Create and append the window manager and rcat dialog
		var windowManager = new OO.ui.WindowManager();
		$( document.body ).append( windowManager.$element );
		var rcatDialog = new ProcessDialog( {size: 'large'} );
		windowManager.addWindows( [rcatDialog] );
		windowManager.openWindow( rcatDialog );
		
		// Workaround for lack of openOnEnter option in Select2 v4
		var select2 = select.data('select2');
		var origKeypressCbs = select2.listeners.keypress;
		var keypressCb = function (evt) {
			if (evt.key === 'Enter' && !select2.isOpen()) {
				rcatDialog.executeAction('save');
				return;
			}
			origKeypressCbs.forEach( (cb) => {cb(evt);} );
		};
		select2.listeners.keypress = [keypressCb];
	}

	/**
	 * Retrieve templates from "Category:Redirect templates"
	 */
	function getRcats(cont='', cmcont='') {
		var query = {
			action:'query', list:'categorymembers', cmlimit:'max',
			cmtitle: config.rcatCat, cmsort:'sortkey', cmnamespace:10,
			cmtype:'page', cmprop: 'title|sortkeyprefix', cmcontinue:cmcont, 
			continue:cont
		};
		new mw.Api().get( query ).done( (result) => {
			if (result?.query?.categorymembers) {
				result.query.categorymembers.forEach( (e) => {
					var tTitle = mw.Title.newFromText(e.title).getMainText();
					if ( tTitle.startsWith('R ') ) {
						var sKey = e.sortkeyprefix.trim() == '' ? 
							tTitle.replace(/^R (from |to |with )?/, '') :
							e.sortkeyprefix;
						params.rcats.push( {sKey: sKey, id: e.title,
							text: '{' + '{' + tTitle + '}}'} );
					}
				} );
				if (result.continue) {
					getRcats(result.continue.continue, result.continue.cmcontinue);
				} else {
					params.rcats.sort( (a, b) => {
						return (a.sKey > b.sKey) ? 1 : ((a.sKey == b.sKey) ? 0 : -1 );
					} );
					showRcatDialog();
				}
			} else {console.warn('error');console.log(result);}
		} ).fail( (e) => {console.warn(e)} );
	}

	/**
	 * Given two titles and talk/subpages,
	 * prompts user to confirm config before swapping the titles
	 */
	function confirmConfig(vData, currSpFlags, destSpFlags, currTSpFlags, destTSpFlags) {
		var vTData = talkValidate(vData.checkTalk, vData.currTalkName, vData.destTalkName);

		// future goal: check empty subpage DESTINATIONS on both sides (subj, talk)
		//   for create protection. disallow move-subpages if any destination is salted

		var noSubpages = currSpFlags.noNeed && destSpFlags.noNeed &&
			currTSpFlags.noNeed && destTSpFlags.noNeed;
		// If one ns disables subpages, other enables subpages, AND HAS subpages,
		//   consider abort. Assume talk pages always safe (TODO fix)
		var subpageCollision = (vData.currNsAllowSubpages && !destSpFlags.noNeed) ||
			(vData.destNsAllowSubpages && !currSpFlags.noNeed);

		// TODO future: currTSpFlags.allowMoveSubpages && destTSpFlags.allowMoveSubpages
		// needs to be separate check. If talk subpages immovable, should not affect subjspace

		if (params.moveSubpages) { 
			if (!subpageCollision && !noSubpages && vData.allowMoveSubpages &&
				(currSpFlags.allowMoveSubpages && destSpFlags.allowMoveSubpages) &&
				(currTSpFlags.allowMoveSubpages && destTSpFlags.allowMoveSubpages))
			{
				params.allSpArr = currSpFlags.spArr.concat(
					destSpFlags.spArr,
					currTSpFlags.spArr,
					destTSpFlags.spArr
				);
			} else if (subpageCollision) {
				params.moveSubpages = false;
				showConfirm("One namespace does not have subpages enabled. Disallowing move subpages.",
					'warning');
			}
		} else {
			showConfirm("Moving subpages disabled.");
		}
		
		params.allSpArr = params.allSpArr ?? [];
		
		// TODO: count subpages and make restrictions?
		if (vData.checkTalk && (!vTData.currTDNE || !vTData.destTDNE || params.moveSubpages)) {
			if (!vTData.allowMoveTalk) {
				params.moveTalk = false;
				showConfirm("Disallowing moving talk. " +
					(!vTData.currTCanCreate ? (vData.currTalkName + " is create-protected. ")
					: (!vTData.destTCanCreate ? (vData.destTalkName + " is create-protected. ")
					: "Talk page is immovable.")), 'warning');
			}
		}
		
		showConfirm("Swapping [["+params.currTitle.title+"]] → [["+params.destTitle.title+"]]");
		showConfirm("Reason: "+params.moveReason);
		if (debug) {
			showConfirm("Move talk: "+params.moveTalk+", Move subpages: "+params.moveSubpages);
			showConfirm("Talk redirect: "+params.talkRedirect+
				", Fix self-redirect: "+params.fixSelfRedirect);
		}
		if (params.moveSubpages && params.allSpArr.length <= 0) showConfirm("No subpages found to move.");
		if (params.fixSelfRedirect && params.apiData?.redirects) preCheckSelfRedirs(vData);
		if ( (params.selfRedirs.length > 0) || 
			(vTData.redirFromTalk && vTData.redirToTalk) ) {
			params.editRedir = true;
			showConfirm(config.newRedirMsg);
			if (vTData.redirFromTalk && vTData.redirToTalk) {
				showConfirm("* Redirect from [[" + vTData.redirFromTalk +
					"]] → [[" +	vTData.redirToTalk + "]] will be created.");
			}
			for (const t in params.selfRedirs) {
				showConfirm("* Self-redirect at [[" + params.selfRedirs[t] + 
				"]] will be re-targeted.");
			}
		}
		
		psProgress.toggle(false);
		showConfirm('', 'notice', true);
		psButton.setDisabled(false).setLabel(config.confirmButton).off('click').on('click', () => {
				psButton.setDisabled(true).setLabel(config.validateButton);
				swapPages(vData, vTData);
			} );
	}

	/**
	 * Given two titles, gathers data on talk/subpages,
	 * then passes that to confirmConfig()
	 */
	function gatherSubpageData() {
		var currSpFlags, destSpFlags, currTSpFlags, destTSpFlags;
		// validate namespaces, not identical, can move
		var ret = parsePagesData();
		const vData = swapValidate(ret);
		if (!vData.valid) {
			showConfirm(vData.invalidReason, 'error');
			return;
		}
		if (vData.addlInfo !== undefined) showConfirm(vData.addlInfo, 'warning');
		
		// subj subpages
		getSubpages(vData.currTitle, false).done( (cData) => {
			currSpFlags = printSubpageInfo(vData.currTitle, cData);
			return getSubpages(vData.destTitle, false);
		} ).then( (dData) => {
			destSpFlags = printSubpageInfo(vData.destTitle, dData);
			// talk subpages
			return getSubpages(vData.currTitle, true);
		} ).then( (cTData) => {
			currTSpFlags = printSubpageInfo(vData.currTalkName, cTData);
			return getSubpages(vData.destTitle, true);
		} ).then( (dTData) => {
			destTSpFlags = printSubpageInfo(vData.destTalkName, dTData);
			confirmConfig(vData, currSpFlags, destSpFlags, currTSpFlags, destTSpFlags);
		} ).fail( (error) => showConfirm(error, 'error') );
	}
	
	function titleInput(title) {
		var nsObj = {value: title.ns || 0, $overlay: true};
		var tObj = {value: title.title || '', $overlay: true};
		if (typeof title.ns !== 'undefined' && typeof title.title !== 'undefined') {
			var re = '^'+mw.config.get("wgFormattedNamespaces")[title.ns]+':';
			tObj.value = title.title.replace(new RegExp(re),'');
		}
		return new mw.widgets.ComplexTitleInputWidget( {namespace: nsObj, title: tObj} );
	}
	
	/**
	 * Determine namespace of title
	 */
	function psParseTitle(data) {
		data = (typeof data === 'object') 
			? mw.Title.makeTitle(data.namespace.value, data.title.value)
			: mw.Title.newFromText(data);
		return data ? {ns: data.namespace, title: data.getPrefixedText()} : null;
	}
	
	/**
	 * If user is able to perform swaps
	 */
	function checkUserPermissions() {
		var ret = {};
		ret.canSwap = true;
		var reslt = $.ajax( {
			url: mw.util.wikiScript('api'), async:false,
			error: (jsondata) => { 
				mw.notify("Swapping pages unavailable.", { title: 'Page Swap Error', type: 'error' } );
				return ret;
			},
			data: { action:'query', format:'json', meta:'userinfo', uiprop:'rights' }
		} ).responseJSON.query.userinfo;
	
		// check userrights for suppressredirect and move-subpages
		var rightslist = reslt.rights;
		ret.canSwap =
				$.inArray('suppressredirect', rightslist) > -1 &&
				$.inArray('move-subpages', rightslist) > -1;
		ret.allowSwapTemplates =
				$.inArray('templateeditor', rightslist) > -1;
		return ret;
	}
	
	/**
	 * Script execution starts here:
	 */
	//Read the old title from the URL or the relevant pagename
	params.currTitle.title = mw.util.getParamValue("wpOldTitle") || mw.config.get("wgRelevantPageName") || '';
	if (document.getElementsByName("wpOldTitle")[0] &&
		document.getElementsByName("wpOldTitle")[0].value != ''
	){
		//If the hidden form field element has a value, use that instead
		params.currTitle.title = document.getElementsByName("wpOldTitle")[0].value;
	}
	//Parse out title and namespace
	params.currTitle = psParseTitle(params.currTitle.title) || {ns: 0, title: params.currTitle.title};
	
	//Read the new title from the URL or make it blank
	params.destTitle.title = mw.util.getParamValue("wpNewTitle") || '';
	//Parse out title and namespace
	params.destTitle = psParseTitle(params.destTitle.title) || {ns: 0, title: params.destTitle.title};
	if (document.getElementsByName("wpNewTitleMain")[0] &&
		document.getElementsByName("wpNewTitleMain")[0].value != '' &&
		document.getElementsByName("wpNewTitleNs")[0]
	){
		//If the Move page form exists, use the values from that instead
		params.destTitle.title = document.getElementsByName("wpNewTitleMain")[0].value;
		params.destTitle.ns = document.getElementsByName("wpNewTitleNs")[0].value;
		if (params.destTitle.ns != 0) {
			params.destTitle.title = mw.config.get("wgFormattedNamespaces")[params.destTitle.ns] + 
				":" + params.destTitle.title;
		}
	}
	
	params.uPerms = checkUserPermissions();
	
	if (!params.uPerms.canSwap) {
		mw.loader.using( [ 'mediawiki.notification' ], () => {
			mw.notify("User rights insufficient for action.", { title: 'Page Swap Error', type: 'error' } );
			return;
		} );
	}
	
	$( '#firstHeading' ).text( (i, t) => (t.replace('Move', 'Swap')) );
	document.title = document.title.replace("Move", "Swap");
	
	new mw.Api().parse(config.introText).done( (parsedText) => {
		$( '#movepagetext' ).replaceWith( $($.parseHTML(parsedText)) );
	} ).fail( (codetr, reslttr) => {
		console.warn( "Error parsing wikitext:\n\n" + config.introText + "\n\n" +
			(reslttr.error.info || (codetr + ".")) );
		$( '#movepagetext' ).html( config.introText );
	} );
		
	var reasonList = [];
	if ($( '#wpReasonList' )[0]) {
		reasonList.push( {
			data: $( '#wpReasonList' ).children("option").get(0).value,
			label: $( '#wpReasonList' ).children("option").get(0).text
		} );
		reasonList.push( {optgroup: $( '#wpReasonList' ).children("optgroup").get(0).label} );
		$( '#wpReasonList' ).children("optgroup").children("option").get().forEach(
			option => reasonList.push( {data: option.value, label: option.text} )
		);
	}
	
	var psFieldset = new OO.ui.FieldsetLayout( {
			label: 'Swap page', classes: ['container'], id: 'psFieldset'
		} ),
		psOldTitle = titleInput(params.currTitle),
		psNewTitle = titleInput(params.destTitle),
		psReasonList = new OO.ui.DropdownInputWidget( {
			options: reasonList, id: 'psReasonList', $overlay: true
		} ),
		psReasonOther = new OO.ui.TextInputWidget( {value: moveReason, id: 'psReasonOther'} ),
		psMovetalk = new OO.ui.CheckboxInputWidget( {selected: params.defaultMoveTalk, id: 'psMovetalk'} ),
		psMoveSubpages = new OO.ui.CheckboxInputWidget( {selected: true, id: 'psMoveSubpages'} ),
		psTalkRedirect = new OO.ui.CheckboxInputWidget( {selected: params.cleanup, id: 'psTalkRedirect'} ),
		psFixSelfRedirect = new OO.ui.CheckboxInputWidget( {selected: params.cleanup, id: 'psFixSelfRedirect'} ),
		psWatch = new OO.ui.CheckboxInputWidget( {selected: false, id: 'psWatch'} ),
		psConfirm = new OO.ui.MessageWidget( {type: 'notice', showClose: false, id: 'psConfirm'} ),
		psButton = new OO.ui.ButtonInputWidget( {
			label: config.validateButton,
			disabled: true, framed: true,
			flags: ['primary','progressive'],
			id: 'psButton'
		} ),
		psProgress = new OO.ui.ProgressBarWidget( {progress: false} ),
		psStatus = new OO.ui.MessageWidget( {type: 'notice', showClose: true, id: 'psStatus'} ),
		psContribsButton = new OO.ui.ButtonWidget( {
			label: 'Open contribs page', title: 'Special:MyContributions',
			href: mw.config.get("wgServer") +
				mw.config.get("wgArticlePath").replace("$1", "Special:MyContributions"),
			framed: true, flags: ['primary', 'progressive'],
			id: 'psContribsButton', target: '_blank'
		} );
	
	psFieldset.addItems( [
		new OO.ui.FieldLayout(psOldTitle, {align: 'top', label: 'Old title:', id: 'psOldTitle'} ),
		new OO.ui.FieldLayout(psNewTitle, {align: 'top', label: 'New title:', id: 'psNewTitle'} ),
		new OO.ui.FieldLayout(psReasonList, {align: 'top', label: 'Reason:'} ),
		new OO.ui.FieldLayout(psReasonOther, {align: 'top', label: 'Other/additional reason:'} ),
		new OO.ui.FieldLayout(psMovetalk, {align: 'inline',
			label: 'Move associated talk page',
			title: 'Move associated talk page'
		} ),
		new OO.ui.FieldLayout(psMoveSubpages, {align: 'inline',
			label: 'Move subpages',
			title: 'Move up to 100 subpages of the source and/or target pages'
		} ),
		new OO.ui.FieldLayout(psTalkRedirect, {align: 'inline',
			label: 'Leave a redirect to new talk page if needed',
			title: 'If one of the pages you\'re swapping has a talk page and ' +
				'the other doesn\'t, create a redirect from the missing talk ' +
				'page to the new talk page location. This is useful when ' +
				'swapping a page with its redirect so that links to the old ' +
				'talk page will continue to work.'
		} ),
		new OO.ui.FieldLayout(psFixSelfRedirect, {align: 'inline',
			label: 'Fix self-redirects',
			title: 'When swapping a page with its redirect, update the ' +
				'redirect to point to the new page name so that it is not ' +
				'pointing to itself. This will not update redirects on subpages.'
		} ),
		new OO.ui.FieldLayout(psWatch, {align: 'inline',
			label: 'Watch source page and target page',
			title: 'Add both source page and target page to your watchlist'
		} ),
		new OO.ui.FieldLayout(psConfirm, {} ),
		new OO.ui.FieldLayout(psButton, {} ),
		new OO.ui.FieldLayout(psProgress, {} ),
		new OO.ui.FieldLayout(psStatus, {} ),
		new OO.ui.FieldLayout(psContribsButton, {} )
		]);
	
	checkTitles();

	/**
	 * Re-check form on any change
	 */
	psOldTitle.namespace.off('change').on( 'change', checkTitles );
	psOldTitle.title.setValidation( (v) => {
		checkTitles(); return (v!='' && params.currTitle.title!=params.destTitle.title);
	} );
	psNewTitle.namespace.off('change').on( 'change', checkTitles );
	psNewTitle.title.setValidation( (v) => {
		checkTitles(); return (v!='' && params.currTitle.title!=params.destTitle.title);
	} );
	psReasonList.off('change').on( 'change', checkTitles );
	psReasonOther.off('change').on( 'change', checkTitles );
	psMovetalk.off('change').on( 'change', checkTitles );
	psMoveSubpages.off('change').on( 'change', checkTitles );
	psTalkRedirect.off('change').on( 'change', checkTitles );
	psFixSelfRedirect.off('change').on( 'change', checkTitles );
	psWatch.off('change').on( 'change', checkTitles );
	
	/**
	 * Set button and status field actions
	 */
	psButton.off('click').on( 'click', clickValidate );
	psStatus.off('close').on( 'close', () => {
		params.statusMessages = [];
		psStatus.setType('notice');
		psContribsButton.toggle(false);
	} ).off('toggle').on( 'toggle', () => {
		if (!psStatus.isVisible()) {
			params.statusMessages = [];
			psStatus.setType('notice');
			psContribsButton.toggle(false);
		}
	} );
	psConfirm.toggle(false);
	psProgress.toggle(false);
	psStatus.toggle(false);
	
	$( '#movepage' ).hide(); //hide old form
	$( '#movepage-loading' ).remove(); //remove loading message
	$( "div.mw-message-box-error" ).hide(); //hide error message
	$( '#psFieldset' ).remove(); //remove old form if script started twice
	$( "div.movepage-wrapper" ).prepend( psFieldset.$element ); //add swap form
	if( !$( '#psFieldset' ).length ){ //something went wrong
		mw.notify(config.errorMsg, {type: 'error', title: "Error:" } );
		document.getElementById("mw-movepage-table").style.display="block";
		$( '#movepage' ).show();
		$( "div.mw-message-box-error" ).show(); 
	}
	var ulStyle = document.createElement('style'); // Even spacing in lists
	ulStyle.innerHTML = '.oo-ui-labelElement-label ul li ul {margin-top: 0.1em;}';
	document.head.appendChild(ulStyle);

	/**
	 * Helper functions that rely on above form elements
	 */
	function checkTitles() {
		if (psOldTitle.namespace.value%2==1 || psNewTitle.namespace.value%2==1) {
			if (psMovetalk.isDisabled() == false) {
				psMovetalk.setDisabled(true);
				params.defaultMoveTalk = psMovetalk.isSelected();
				psMovetalk.setSelected(false);
			}
		} else if (psMovetalk.isDisabled()) {
			psMovetalk.setDisabled(false);
			psMovetalk.setSelected(params.defaultMoveTalk);
		}
		psConfirm.toggle(false).setType('notice');
		params.currTitle = psParseTitle(psOldTitle);
		params.destTitle = psParseTitle(psNewTitle);
		var titlesMatch = (params.currTitle?.title==params.destTitle?.title);
		psOldTitle.title.setValidityFlag(params.currTitle && !titlesMatch );
		psNewTitle.title.setValidityFlag(params.destTitle && !titlesMatch );
		psButton.setLabel(config.validateButton).off('click').on('click', clickValidate
			).setDisabled(psOldTitle.title.value=='' || psNewTitle.title.value=='' || titlesMatch );
	}
	
	function clickValidate() {
		psConfirm.toggle(false).setType('notice');
		psStatus.toggle(false).setType('notice');
		psButton.setDisabled(true).setLabel(config.validatingButton);
		psProgress.toggle(true);
		Object.assign(params, params, {
			confirmMessages: [],
			statusMessages: [],
			currTitle: psParseTitle(psOldTitle),
			destTitle: psParseTitle(psNewTitle),
			moveReason: psReasonOther.value,
			moveTalk: psMovetalk.isDisabled() ? false : psMovetalk.selected,
			moveSubpages: psMoveSubpages.selected,
			talkRedirect: psTalkRedirect.selected,
			fixSelfRedirect: psFixSelfRedirect.selected,
			watch: psWatch.selected ? 'watch' : 'unwatch',
		} );
		
		if (!params.currTitle) {
			showConfirm("Title '" + psOldTitle + "' is invalid.", 'error');
			return;
		} else if (!params.destTitle) {
			showConfirm("Title '" + psNewTitle + "' is invalid.", 'error');
			return;
		}
		
		if (psReasonList.value != 'other') {
			params.moveReason = psReasonList.value +
				(psReasonOther.value == '' ? '' : '. ' + psReasonOther.value); 
		} else if (psReasonOther.value == '') {
			params.moveReason = 'Swap [[' + params.currTitle.title + ']] and [[' +
				params.destTitle.title + ']] ([[WP:SWAP]])';
		}
		
		var queryTitleArr = [params.currTitle.title, params.destTitle.title];
		queryTitleArr.forEach(
				(v) => queryTitleArr.push(mw.Title.newFromText( v ).getTalkPage( ).getPrefixedText())
			);
		params.titlesString = " [[" + queryTitleArr.join(']] or [[') + "]]";
		var queryData = {action:'query', format:'json', titles: queryTitleArr.join('|'),
				prop:'info', intestactions:'move|create', 
				list:'logevents', leprop:'timestamp', letype:'move', letitle: params.currTitle.title, lelimit:'1',
				meta:'siteinfo', siprop:'namespaces'
			};
		new mw.Api().get( queryData	).then( (data) => {
			if (data && data?.query?.namespaces) params.apiData = data.query;
			return new mw.Api().get( {
				action:'query', format:'json',
				redirects:'', titles: queryTitleArr.join('|')
			} );
		} ).then( (rData) => {
			if (rData && Object.keys(params.apiData).length > 0) {
				params.apiData.redirects = rData?.query?.redirects;
				gatherSubpageData();
			} else {
				showConfirm("Error parsing API data on" + params.titlesString + ".",
					'error');
			}
		} ).fail ( (codetr, reslttr) => {
			showConfirm("Error fetching API data on" + params.titlesString + ": " + 
				(reslttr.error.info || (codetr + ".")), 'error');
		} );
	}
	return true;
}