MediaWiki:Gadget-MassProtect.js

お知らせ: 保存した後、ブラウザのキャッシュをクリアしてページを再読み込みする必要があります。

多くの WindowsLinux のブラウザ

  • Ctrl を押しながら F5 を押す。

Mac における Safari

  • Shift を押しながら、更新ボタン をクリックする。

Mac における ChromeFirefox

  • Cmd Shift を押しながら R を押す。

詳細についてはWikipedia:キャッシュを消すをご覧ください。

/********************************************************************************************************\

	MassProtect

	Protect multiple pages at one fell swoop. This script creates a special page interface on:
		* [[Special:MassProtect]]
		* [[Special:MP]]
		* [[Special:一括保護]]
	A portlet link to [[Special:MassProtect]] is added to the toolbox if the current page is none of
	the above. If the debugging mode is on, the page path is changed to [[Special:MPD]] so that the
	developer does not need to disable the official version of this script in the user preferences.

	@link https://ja.wikipedia.org/wiki/Help:MassProtect
	@author [[User:Dragoniez]]

\********************************************************************************************************/
//<nowiki>

(function() { // Container IIFE

// *************************************************** INITIALIZATION ***************************************************

/** @readonly */
var MP = 'MassProtect';

/**
 * When enabled, the path of the special page is changed.
 * @readonly
 */
var debuggingMode = false;

// Check user rights
var userRights = {
	protect: ['sysop', 'global-interface-editor', 'global-sysop', 'new-wikis-importer', 'staff', 'steward'],
	apihighlimits: ['sysop', 'apihighlimits-requestor', 'global-sysop', 'staff', 'steward', 'sysadmin', 'wmf-researcher']
};
var canProtect = false;
var hasApiHighLimits = false;
// @ts-ignore
mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups')).some(function(group) {
	if (!canProtect) {
		canProtect = userRights.protect.indexOf(group) !== -1;
	}
	if (!hasApiHighLimits) { // Bots excluded
		hasApiHighLimits = userRights.apihighlimits.indexOf(group) !== -1;
	}
	return canProtect && hasApiHighLimits; // Get out of the loop when all entries have become true
});

/** @readonly */
var apilimit = hasApiHighLimits ? 500 : 50;

/** @readonly */
var optionName = 'userjs-mp-targetpages';

/** @type {mw.Api} @readonly */
var api;

// Entry point
$.when(
	mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.user', 'mediawiki.Title', 'jquery.ui']),
	$.ready
).then(function() { // When dependent modules and the DOM are loaded

	api = new mw.Api();

	// Create an interface on Special:MassProtect, or else create a portlet link to the special page
	var titleRegex = debuggingMode ? /^mpd$/i : /^(massprotect|mp|一括保護)$/i;
	var titlePath = debuggingMode ? 'Special:MPD' : 'Special:MassProtect';
	if (mw.config.get('wgNamespaceNumber') === -1 && titleRegex.test(mw.config.get('wgTitle'))) {

		document.title = '一括保護 - Wikipedia';

		if (canProtect) {
			createInterface();
		} else {
			showUserRightError();
		}

	} else {
		mw.util.addPortletLink(
			'p-tb',
			mw.util.getUrl(titlePath),
			'一括保護',
			't-mp',
			'ページを一括で保護する'
		);
	}

});

// *************************************************** MAIN FUNCTIONS ***************************************************

/** Create the MassProtect interface. */
function createInterface() {

	// style tag
	var style = document.createElement('style');
	style.textContent =
		// General
		'#mp-container legend {' +
			'font-weight: bold;' +
		'}' +
		'#mp-container fieldset {' +
			'border-color: #a2a9b1;' +
		'}' +
		'#mp-container input[type="text"],' +
		'#mp-container textarea,' +
		'#mp-container select {' +
			'box-sizing: border-box;' +
		'}' +
		'.mp-forcehidden {' +
			'display: none !important;' +
		'}' +
		// Tab switcher
		'#mp-targets-tab {' +
			'display: flex;' +
			'flex-wrap: wrap;' +
		'}' +
		'#mp-targets-tab > input[type="radio"] {' +
			'display: none;' +
		'}' +
		'#mp-targets-tab > label {' +
			'padding: 0.3em 0.5em;' +
			'cursor: pointer;' +
			'width: 8em;' +
			'background-color: lightgray;' +
			'border: 1px solid #a2a9b1;' +
			'text-align: center;' +
		'}' +
		'#mp-targets-tab > label:not(:first-child) {' +
			'border-left: none;' +
		'}' +
		'#mp-targets-tab > div {' +
			'order: 1;' +
			'width: 100%;' +
			'display: none;' +
			'margin-top: 0.5em;' +
		'}' +
		'#mp-targets-tab > input[type="radio"]:checked+label {' +
			'background-color: deepskyblue;' +
		'}' +
		'#mp-targets-tab > input[type="radio"]:disabled+label {' +
			'text-decoration: line-through;' +
			'color: rgba(0,0,0,0.4);' +
		'}' +
		'#mp-targets-tab > input[type="radio"]:checked+label+div {' +
			'display: initial;' +
		'}' +
		// Pagetitle fetcher's mode switcher
		'.mp-targets-fetcher-settings {' +
			'display: none;' +
		'}' +
		'#mp-targets-fetcher-mode[data-chosen="テンプレート"]+div {' +
			'display: block;' +
		'}' +
		'#mp-targets-fetcher-mode[data-chosen="カテゴリ"]+div+div {' +
			'display: block;' +
		'}' +
		'#mp-targets-fetcher-mode[data-chosen="投稿記録"]+div+div+div {' +
			'display: block;' +
		'}' +
		'#mp-targets-fetcher-mode[data-chosen="リンク選択"]+div+div+div+div {' +
			'display: block;' +
		'}' +
		// Fetchet contribs - Numeral input toggle
		'#mp-targets-fetcher-contribs-intersect-count {' +
			'display: none;' +
		'}' +
		'#mp-targets-fetcher-contribs-intersect:checked + label + #mp-targets-fetcher-contribs-intersect-count {' +
			'display: inline;' +
		'}' +
		// Link selector dialog
		'.mp-targets-fetcher-linkselector-dialog-invalidlink {' +
			'color: #64af70 !important;' +
		'}' +
		'.mp-targets-fetcher-linkselector-dialog-selectedlink {' +
			'background-color: orange;' +
		'}' +
		// jQuery UI toolip (line breaks by '\n')
		'.mp-tooltip {' +
			'white-space: pre-line;' +
		'}' +
		// Show/hide toggle for watchlist expiry dropdown
		'#mp-settings-watch-expiry-container {' +
			'display: none;' +
		'}' +
		'#mp-settings-watch:checked + label + #mp-settings-watch-expiry-container {' +
			'display: block;' +
		'}' +
		// Show/hide toggle for Pp selectors
		'#mp-settings-addpp-selector {' +
			'display: none;' +
		'}' +
		'#mp-settings-addpp:checked ~ #mp-settings-addpp-selector {' +
			'display: block;' +
		'}' +
		// Show/hide toggle for the confirm-to-unprotect checkbox
		'#mp-settings-confirmtounprotect-container {' +
			'display: none;' +
		'}' +
		'#mp-settings-confirmtooverwrite:checked ~ #mp-settings-confirmtounprotect-container {' +
			'display: block;' +
		'}';
	document.head.appendChild(style);

	// The form
	var container = document.createElement('div');
	container.id = 'mp-container';
	container.innerHTML =
		'<div id="mp-policy">' +
			'<p>' +
				'指定されたページ群の保護レベルを一括変更できます。変更する場合は、' +
				'<a href="' + mw.util.getUrl('Wikipedia:保護の方針') + '" title="Wikipedia:保護の方針">保護の方針</a>、' +
				'<a href="' + mw.util.getUrl('Wikipedia:拡張半保護の方針') + '" title="Wikipedia:拡張半保護の方針">拡張半保護の方針</a>、' +
				'<a href="' + mw.util.getUrl('Wikipedia:半保護の方針') + '" title="Wikipedia:半保護の方針">半保護の方針</a>、' +
				'に基づいているか確認して下さい。' +
			'</p>' +
			'<ul>' +
				'<li>有効期限のデフォルトは無期限です。適切な期間・期限を指定してください。</li>' +
				'<ul>' +
					'<li>' +
						'「その他の期間」の記入例 (' +
						'<a href="http://www.gnu.org/software/tar/manual/html_node/Date-input-formats.html">GNU標準フォーマット</a>' +
						'): "12 hours"、"5 days"、"3 weeks"、"2012-09-25 20:00"' +
						' (日時は<a href="' + mw.util.getUrl('協定世界時') + '" title="協定世界時">UTC</a>)' +
					'</li>' +
				'</ul>' +
				'<li>' +
					'保護レベルを変更した場合、ページ上で保護テンプレート (' +
					'<a href="' + mw.util.getUrl('Template:Pp') + '">Template:Pp</a>' +
					') を更新してください。' +
				'</li>' +
			'</ul>' +
		'</div>';
	replaceContent(container, '一括保護');

	/**
	 * Enable/disable select, input, and textarea elements in the mp-container.
	 * @param {boolean} disable
	 */
	var toggleDisableAttributes = function(disable) {
		Array.prototype.forEach.call(container.querySelectorAll('select, input, textarea'),
			/** @param {HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement} el */
			function(el) {
				// Originally disabled elements shouldn't be enabled
				if (disable) {
					if (el.disabled) { // If originally disabled
						el.dataset.disabled = '1'; // Set a temporary data attribute
					} else {
						el.disabled = true;
					}
				} else {
					if (el.dataset.disabled === '1') { // Don't enable if originally disabled
						el.dataset.disabled = '0';
					} else {
						el.disabled = false;
					}
				}
			}
		);
	};

	// Field for protection targets
	var tgtWrapper = document.createElement('fieldset');
	tgtWrapper.id = 'mp-targets';
	tgtWrapper.innerHTML = '<legend>対象ページ</legend>';
	container.appendChild(tgtWrapper);

	// Field for protection targets - Tab container
	var tgtTab = document.createElement('div');
	tgtTab.id = 'mp-targets-tab';
	tgtWrapper.appendChild(tgtTab);

	var tabCnt = 0;
	/**
	 * Create elements for a new tab and append them to the tab container.
	 * ```
	 * <input id="mp-targets-tabN" type="radio" name="tab">
	 * <label for="mp-targets-tabN">labelText</label>
	 * <div></div>
	 * ```
	 * @param {string} labelText
	 * @param {string} contentId
	 * @returns {{radio: HTMLInputElement; label: HTMLLabelElement; content: HTMLDivElement;}}
	 */
	var createTab = function(labelText, contentId) {

		var id = 'mp-targets-tab' + (++tabCnt);

		var radio = document.createElement('input');
		radio.type = 'radio';
		if (tabCnt === 1) radio.checked = true;
		radio.name = 'tab';
		radio.id = id;
		tgtTab.appendChild(radio);

		var label = document.createElement('label');
		label.htmlFor = id;
		label.textContent = labelText;
		tgtTab.appendChild(label);

		var content = document.createElement('div');
		content.id = contentId;
		tgtTab.appendChild(content);

		return {radio: radio, label: label, content: content};

	};

	// Field for protection targets - Textbox tab
	var tgtTab1 = createTab('入力フィールド', 'mp-targets-input');
	var tgtInput = document.createElement('textarea');
	tgtInput.style.cssText = 'width: 100%; font-family: inherit; padding: 0.3em;';
	tgtInput.rows = 20;
	tgtInput.placeholder = 'ページ名ごとに改行';
	tgtTab1.content.appendChild(tgtInput);

	/**
	 * @typedef TitleConfig
	 * @type {object} Only one config can be specified on one function call.
	 * @property {string[]} [add] Add these titles.
	 * @property {string[]} [remove] Remove these titles.
	 * @property {string[]} [replace] Replace with these titles.
	 */
	/**
	 * @typedef Pg
	 * @type {object}
	 * @property {string[]} titles An array of tidied-up pagetitles (first letter is in uppercase and spaces are represented by underscores).
	 * @property {mw.Title[]} mwTitles
	 */
	/**
	 * Clean up pagetitles in the textbox and return an object containing two arrays of pagetitles.
	 * @param {TitleConfig} [titleConfig]
	 * @returns {Pg}
	 */
	var cleanupPagetitles = function(titleConfig) {

		titleConfig = titleConfig || {};
		var sourceArr = (titleConfig.replace || tgtInput.value.replace(/\u200e/g, '').split('\n')).concat(titleConfig.add || []);
		var pg = sourceArr.reduce(/** @param {Pg} acc */ function(acc, title) {
			try {
				var mwTitle = new mw.Title(title); // Can throw an error
				if (!mwTitle.canHaveTalkPage()) return acc;
				title = mwTitle.getPrefixedDb();
				if (acc.titles.indexOf(title) === -1) { // No duplicates
					if (!(titleConfig && titleConfig.remove && titleConfig.remove.indexOf(title) !== -1)) { // Ignore titles in titleConfig.remove
						acc.titles.push(title);
						acc.mwTitles.push(mwTitle);
					}
				}
			}
			catch (err) {}
			return acc;
		}, Object.create({titles: [], mwTitles: []}));

		tgtInput.value = pg.titles.join('\n');
		tgtInputLength.textContent = pg.titles.length.toString(); // tgtInputLength - variable declared below
		return pg;

	};

	var tgtInputCleanup = createButton(tgtTab1.content, 'mp-targets-input-cleanup', '整形', {buttonCss: 'margin: 0.5em 1em 0 0;'});
	tgtInputCleanup.addEventListener('click', function(e) {
		cleanupPagetitles();
	});

	tgtTab1.content.appendChild(document.createTextNode('ページ数: '));
	var tgtInputLength = document.createElement('span');
	tgtInputLength.id = 'mp-targets-input-length';
	tgtInputLength.textContent = '0';
	tgtTab1.content.appendChild(tgtInputLength);

	// Field for protection targets - List tab
	var tgtTab2 = createTab('リスト表示', 'mp-targets-list');
	var tgtTab2Body = document.createElement('div');
	tgtTab2Body.style.cssText = 'margin: 0; overflow-y: scroll; border: 1px solid #a2a9b1;';
	tgtTab2Body.style.height = tgtInput.offsetHeight + 'px'; // Set this div the same height as that of the textarea
	tgtTab2.content.appendChild(tgtTab2Body);
	var tgtListOl = document.createElement('ol');
	tgtListOl.style.margin = '0.3em 0 0 2em';
	tgtTab2Body.appendChild(tgtListOl);
	var tgtListGetLog = createButton(tgtTab2.content, 'mp-targets-list-protectionlog', '保護記録の詳細を取得', {buttonCss: 'margin: 0.5em 1em 0 0;'});
	var tgtListGetLogProgress = document.createElement('span');
	tgtTab2.content.appendChild(tgtListGetLogProgress);
	var getLogTimeout;
	tgtListGetLog.addEventListener('click', function() {

		toggleDisableAttributes(true);
		clearTimeout(getLogTimeout);
		tgtListGetLogProgress.innerHTML = '';
		tgtListGetLogProgress.appendChild(getIcon('doing'));
		tgtListGetLogProgress.appendChild(document.createTextNode(' 取得中'));

		var pg = cleanupPagetitles();
		getProtectionLogs(pg.titles).then(function(status) {

			Array.prototype.forEach.call(tgtListOl.querySelectorAll('.mp-targets-list-logs'), /** @param {HTMLSpanElement} el */ function(el) {
				var title = el.dataset.title;
				var logObj, display;
				if (title && (logObj = status[title])) {
					display = '保護歴: ' + logObj.previous.count.toString() + '回 ';
					if (!logObj.current) { // Currently not protected
						if (logObj.previous.count) {
							display += '最終保護: ' + logObj.previous.latest.join(', ');
						}
					} else { // Currently protected
						display += '<b>保護中</b>: ' + logObj.current.level.join(', ');
					}
				} else {
					display = '保護記録の詳細が取得できませんでした';
				}
				el.innerHTML = display;
				el.style.display = 'block';
			});

			toggleDisableAttributes(false);
			tgtListGetLogProgress.innerHTML = '';
			tgtListGetLogProgress.appendChild(getIcon('done'));
			tgtListGetLogProgress.appendChild(document.createTextNode(' 取得しました'));
			getLogTimeout = setTimeout(function() {
				tgtListGetLogProgress.innerHTML = '';
			}, 5000);

		});

	});

	/**
	 * @typedef ListObject
	 * @type {{
	 *	titles: string[];
	 *	existingTitles: string[];
	 *	existingFiles: string[];
	 *	missingTitles: string[];
	 *	progress: Progress;
	 * }}
	 */
	/**
	 * An object of pagetitle-object pairs.
	 * @typedef Progress
	 * @type {Object.<string, ProgressObjVal>}
	 */
	/**
	 * @typedef ProgressObjVal
	 * @type {object}
	 * @property {HTMLLIElement} list
	 * @property {{pagetitle: string; link: HTMLAnchorElement;}} maintitle Used only in createList
	 * @property {{pagetitle: string; link: HTMLAnchorElement;}} subtitle Used only in createList
	 * @property {HTMLSelectElement} expiry
	 * @property {HTMLInputElement} liremove
	 * @property {HTMLSpanElement} logs
	 * @property {{label: HTMLElement; msg: HTMLSpanElement;}} progress
	 * @property {{label: HTMLElement; msg: HTMLSpanElement;}} progress2
	 */

	/**
	 * A storage object of created list items.
	 * @type {Progress}
	 */
	var progressStorage = {};

	/**
	 * Create list items in the \<ol> element of the list tab out of pagetitles in the textbox field. When the list is created,
	 * an API request is sent to check whether the listed pages exist and mark missing pages in red.
	 * @param {boolean} protectionPrep If true, hide the expiry dropdown, remove button, and protection log line.
	 * @param {TitleConfig} [titleConfig]
	 * @returns {JQueryPromise<ListObject|undefined>} Returns undefined if no pagetitle can be fetched from the textbox field.
	 */
	var createList = function(protectionPrep, titleConfig) {

		var pg = cleanupPagetitles(titleConfig);
		if (!pg.mwTitles.length) return $.Deferred().resolve(undefined);
		tgtListOl.innerHTML = '';

		/** @type {Object.<string, HTMLAnchorElement[]>} */
		var anchors = {};

		var progressObj = pg.mwTitles.reduce(/** @param {Progress} acc */ function(acc, Title) {

			var title = Title.getPrefixedDb();

			/** @type {ProgressObjVal} */
			var progressObjVal;
			if (progressStorage[title]) { // If list item for this title has already been created, use it

				progressObjVal = progressStorage[title];

			} else { // Or else create a new list item

				var li = document.createElement('li');
				li.style.marginBottom = '0.2em';

				var titleLink = document.createElement('a');
				titleLink.href = mw.util.getUrl(title, {redirect: 'no'});
				titleLink.classList.add('mp-targets-list-itemtitle');
				titleLink.target = '_blank';
				titleLink.textContent = title;
				titleLink.dataset.title = title;
				li.appendChild(titleLink);
				li.appendChild(document.createTextNode(' ('));

				var relPageLink = document.createElement('a');
				/** @type {string} */
				var relPageTitle;
				if (Title.isTalkPage()) {
					// @ts-ignore
					relPageTitle = Title.getSubjectPage().getPrefixedDb();
					relPageLink.textContent = 'メイン';
				} else {
					// @ts-ignore
					relPageTitle = Title.getTalkPage().getPrefixedDb();
					relPageLink.textContent = 'ノート';
				}
				relPageLink.href = mw.util.getUrl(relPageTitle, {redirect: 'no'});
				relPageLink.target = '_blank';
				relPageLink.dataset.title = relPageTitle;
				li.appendChild(relPageLink);
				li.appendChild(document.createTextNode(' | '));

				var historyLink = document.createElement('a');
				historyLink.href = mw.util.getUrl(title, {action: 'history'});
				historyLink.target = '_blank';
				historyLink.textContent = '履歴';
				li.appendChild(historyLink);
				li.appendChild(document.createTextNode(' | '));

				var logLink = document.createElement('a');
				logLink.href = mw.util.getUrl('Special:Log', {page: title});
				logLink.target = '_blank';
				logLink.textContent = '記録';
				li.appendChild(logLink);
				li.appendChild(document.createTextNode(' | '));

				var deleteLogLink = document.createElement('a');
				deleteLogLink.href = mw.util.getUrl('Special:Log/protect', {page: title});
				deleteLogLink.target = '_blank';
				deleteLogLink.textContent = '保護記録';
				li.appendChild(deleteLogLink);
				li.appendChild(document.createTextNode(')'));

				var expirySelector = document.createElement('select');
				expirySelector.style.marginLeft = '0.5em';
				expirySelector.innerHTML =
					'<option value=""></option>' +
					'<option value="indefinite">無期限</option>' +
					'<option value="1 week">1週間</option>' +
					'<option value="2 weeks">2週間</option>' +
					'<option value="1 month">1ヵ月</option>' +
					'<option value="3 months">3ヵ月</option>' +
					'<option value="6 months">6ヵ月</option>' +
					'<option value="1 year">1年</option>' +
					'<option value="3 years">3年</option>';
				li.appendChild(expirySelector);

				var removeButton = createButton(li, '', '除去', {buttonCss: 'margin-left: 0.5em;'});
				removeButton.classList.add('mp-targets-list-deleteitem');
				removeButton.addEventListener('click', function() {
					cleanupPagetitles({remove: [title]});
					li.remove();
				});

				var progressWrapper = document.createElement('span');
				progressWrapper.classList.add('mp-targets-list-progress-wrapper');
				progressWrapper.style.cssText = 'display: inline-block; margin-left: 0.5em;';
				li.appendChild(progressWrapper);
				var progressLabel = document.createElement('b');
				progressWrapper.appendChild(progressLabel);
				var progressMsg = document.createElement('span');
				progressMsg.classList.add('mp-targets-list-progress');
				progressWrapper.appendChild(progressMsg);

				var progress2Wrapper = document.createElement('span');
				progress2Wrapper.classList.add('mp-targets-list-progress2-wrapper');
				progress2Wrapper.style.cssText = 'display: inline-block; margin-left: 0.5em;';
				li.appendChild(progress2Wrapper);
				var progress2Label = document.createElement('b');
				progress2Wrapper.appendChild(progress2Label);
				var progress2Msg = document.createElement('span');
				progress2Msg.classList.add('mp-targets-list-progress2');
				progress2Wrapper.appendChild(progress2Msg);

				var logs = document.createElement('span');
				logs.style.display = 'none';
				logs.classList.add('mp-targets-list-logs');
				logs.dataset.title = title;
				li.appendChild(logs);

				progressStorage[title] = progressObjVal = {
					list: li,
					maintitle: {
						pagetitle: title,
						link: titleLink
					},
					subtitle: {
						pagetitle: relPageTitle,
						link: relPageLink
					},
					expiry: expirySelector,
					liremove: removeButton,
					logs: logs,
					progress: {
						label: progressLabel,
						msg: progressMsg
					},
					progress2: {
						label: progress2Label,
						msg: progress2Msg
					}
				};

			}

			tgtListOl.appendChild(progressObjVal.list);

			if (protectionPrep) {
				[progressObjVal.expiry, progressObjVal.liremove, progressObjVal.logs].forEach(function(el) {
					el.classList.add('mp-forcehidden');
				});
			}

			if (anchors[progressObjVal.maintitle.pagetitle]) {
				anchors[progressObjVal.maintitle.pagetitle].push(progressObjVal.maintitle.link);
			} else {
				anchors[progressObjVal.maintitle.pagetitle] = [progressObjVal.maintitle.link];
			}
			if (anchors[progressObjVal.subtitle.pagetitle]) {
				anchors[progressObjVal.subtitle.pagetitle].push(progressObjVal.subtitle.link);
			} else {
				anchors[progressObjVal.subtitle.pagetitle] = [progressObjVal.subtitle.link];
			}

			acc[title] = progressObjVal;
			return acc;

		}, Object.create(null));

		mw.hook('wikipage.content').fire(mw.util.$content);
		return checkPageExistence(Object.keys(anchors)).then(function(pageInfoArray) {

			/** @type {string[]} */
			var existingTitles = [];
			/** @type {string[]} */
			var existingFiles = [];
			/** @type {string[]} */
			var missingTitles = [];

			pageInfoArray.forEach(function(obj) {

				// Blue links for existing titles, red links for missing ones
				if (anchors[obj.title]) {
					anchors[obj.title].forEach(function(a) {
						if (obj.missing) {
							a.classList.add('new');
						} else {
							a.classList.remove('new');
						}
					});
				}

				// For the main pagetitles that are to be protected, save their existence states
				if (pg.titles.indexOf(obj.title) !== -1) { // Main titles only
					if (obj.missing) {
						if (missingTitles.indexOf(obj.title) === -1) { // No duplicates
							missingTitles.push(obj.title);
						}
					} else if (getNamespaceNumber(obj.title) === 6) { // Existing files
						if (existingFiles.indexOf(obj.title) === -1) {
							existingFiles.push(obj.title);
						}
					} else {
						if (existingTitles.indexOf(obj.title) === -1) {
							existingTitles.push(obj.title);
						}
					}
				}

			});

			return {
				titles: pg.titles,
				existingTitles: existingTitles,
				existingFiles: existingFiles,
				missingTitles: missingTitles,
				progress: progressObj
			};

		});

	};

	/** Remove the 'mp-forcehidden' class from all elements. */
	var removeForceHidden = function() {
		Array.prototype.forEach.call(container.querySelectorAll('.mp-forcehidden'), function(el) {
			el.classList.remove('mp-forcehidden');
		});
	};

	tgtTab2.radio.addEventListener('change', function() {
		createList(false).then(function(titlesGiven) {
			if (!titlesGiven) {
				tgtTab1.radio.checked = true;
				alert('対象ページが入力されていません。');
			}
		});
	});

	// Field for save button
	var saveButtonWrapper = document.createElement('div');
	saveButtonWrapper.id = 'mp-targets-save';
	saveButtonWrapper.style.marginTop = '0.5em';
	tgtWrapper.appendChild(saveButtonWrapper);

	var saveButton = createButton(saveButtonWrapper, '', '保存');
	var getSavedButton = createButton(saveButtonWrapper, '', '保存済みページを取得', {buttonCss: 'margin-left: 1em;'});
	if (!mw.user.options.get(optionName)) getSavedButton.style.display = 'none';
	getSavedButton.addEventListener('click', function(e) {
		/** @type {string|null} */
		var savedPages = mw.user.options.get(optionName);
		var pagetitles;
		if (savedPages && (pagetitles = JSON.parse(savedPages)).length) {
			if (tgtTab2.radio.checked) {
				createList(false, {add: pagetitles});
			} else {
				cleanupPagetitles({add: pagetitles});
			}
		}
	});
	saveButtonWrapper.appendChild(getSavedButton);

	var saveProgress = document.createElement('span');
	saveProgress.style.cssText = 'margin-left: 1em; display: inline-block;';
	saveButtonWrapper.appendChild(saveProgress);

	var saveTimeout;
	saveButton.addEventListener('click', function(e) {

		var pg = cleanupPagetitles();
		var reset = !pg.titles.length;
		if (reset) {
			if (!confirm('保存済みページを初期化します。よろしいですか?')) return;
		}
		toggleDisableAttributes(true);
		var data = reset ? null : JSON.stringify(pg.titles);

		clearTimeout(saveTimeout);
		saveProgress.innerHTML = getIcon('doing').outerHTML + ' 保存中';

		// @ts-ignore
		api.saveOption(optionName, data)
			.then(function(res) {
				console.log(MP, res);
				toggleDisableAttributes(false);
				saveProgress.innerHTML = getIcon('done').outerHTML + ' 保存完了';
				getSavedButton.style.display = reset ? 'none' : 'inline-block';
				mw.user.options.set(optionName, data);
			})
			.catch(function(code, err) {
				console.error(MP, err);
				toggleDisableAttributes(false);
				saveProgress.innerHTML = getIcon('failed').outerHTML + ' 保存失敗 (' + code + ')';
			})
			.then(function() {
				saveTimeout = setTimeout(function(){
					saveProgress.innerHTML = '';
				}, 5000);
			});

	});

	// Field for protection targets - Fetcher container
	var fetcherWrapper = document.createElement('fieldset');
	fetcherWrapper.id = 'mp-targets-fetcher';
	fetcherWrapper.style.marginTop = '1em';
	fetcherWrapper.innerHTML = '<legend>一括取得</legend>';
	tgtWrapper.appendChild(fetcherWrapper);

	/**
	 * Create a width-fixed label for a fetcher option.
	 * @param {HTMLElement} appendTo
	 * @param {string} labelText
	 * @param {boolean} [appendBr] Append \<br> before the label if true.
	 * @returns {HTMLLabelElement}
	 */
	var createFetcherLabel = function(appendTo, labelText, appendBr) {
		if (appendBr) appendTo.appendChild(document.createElement('br'));
		var label = document.createElement('label');
		label.style.cssText = 'display: inline-block; width: 7em;';
		label.textContent = labelText;
		appendTo.appendChild(label);
		return label;
	};

	/**
	 * Create a fetcher option textbox.
	 * @param {HTMLElement} appendTo
	 * @param {string} textboxId
	 * @param {string} labelText
	 * @param {boolean} [appendBr] Append \<br> before the label if true.
	 * @returns {HTMLInputElement}
	 */
	var createFetcherTextbox = function(appendTo, textboxId, labelText, appendBr) {
		return createLabeledTextbox(appendTo, textboxId, labelText, {labelCss: 'width: 7em;', textboxCss: 'width: 28em;', appendBr: !!appendBr});
	};

	/**
	 * @typedef FetcherRegexOption
	 * @type {object}
	 * @property {HTMLLabelElement} label
	 * @property {HTMLInputElement} regexMode
	 * @property {HTMLInputElement} caseInsensitive
	 */
	/**
	 * Create a regex fetcher option (empty label and two checkboxes).
	 * @param {HTMLElement} appendTo
	 * @param {string} checkboxIdFragment mp-targets-fetcher-XXXX-regex
	 * @param {boolean} [appendBr] Append \<br> before the label if true.
	 * @returns {FetcherRegexOption}
	 */
	var createFetcherRegexOption = function(appendTo, checkboxIdFragment, appendBr) {

		var label = createFetcherLabel(appendTo, '', !!appendBr);
		var id = 'mp-targets-fetcher-' + checkboxIdFragment + '-regex';

		var chRegex = createLabeledCheckbox(appendTo, id, '正規表現モード', {checkboxCss: 'margin-left: 1em;'});
		id += '-caseinsensitive';
		var chRegexCI = createLabeledCheckbox(appendTo, id, '大文字小文字を区別しない', {checkboxCss: 'margin-left: 1em;'});

		return {label: label, regexMode: chRegex, caseInsensitive: chRegexCI};

	};

	/**
	 * Set a dynamic placeholder for a regex setting textbox.
	 * @param {HTMLInputElement} textbox
	 * @param {HTMLInputElement} checkbox
	 */
	var setRegexPlaceholder = function(textbox, checkbox) {
		var defaultMsg = '複数指定する場合はパイプで分割';
		textbox.placeholder = defaultMsg;
		checkbox.addEventListener('change', function() {
			textbox.placeholder = this.checked ? '一文字目は大文字扱い・スペースは"_"扱い' : defaultMsg;
		});
	};

	// Field for protection targets - Fetcher - Mode dropdown
	var fetcherMode = createLabeledDropdown(fetcherWrapper, 'mp-targets-fetcher-mode', 'モード', {labelCss: 'width: 7em;'});
	fetcherMode.onchange = function() { // CSS looks at data-chosen to decide which field to show below the mode dropdown
		fetcherMode.dataset.chosen = fetcherMode.value;
	};
	fetcherMode.innerHTML =
		'<option value="テンプレート">テンプレート</option>' +
		'<option value="カテゴリ">カテゴリ</option>' +
		'<option value="投稿記録">投稿記録</option>' +
		'<option value="リンク選択">リンク選択</option>';
	fetcherMode.selectedIndex = 2; // Contribs fetcher by default
	fetcherMode.dataset.chosen = '投稿記録';
	fetcherWrapper.appendChild(fetcherMode);

	// Field for protection targets - Fetcher - By template
	var fetcherTemplateWrapper = document.createElement('div');
	fetcherTemplateWrapper.classList.add('mp-targets-fetcher-settings');
	fetcherTemplateWrapper.id = 'mp-targets-fetcher-template';
	fetcherWrapper.appendChild(fetcherTemplateWrapper);
	var fetcherTemplatePagetitle = createFetcherTextbox(fetcherTemplateWrapper, 'mp-targets-fetcher-template-pagetitle', 'ページ');
	var fetcherTemplateTitle = createFetcherTextbox(fetcherTemplateWrapper, 'mp-targets-fetcher-template-title', 'テンプレート', true);
	var fetcherTemplateTitleRegex = createFetcherRegexOption(fetcherTemplateWrapper, 'template-title', true);
	setRegexPlaceholder(fetcherTemplateTitle, fetcherTemplateTitleRegex.regexMode);
	var fetcherTemplateSection = createFetcherTextbox(fetcherTemplateWrapper, 'mp-targets-fetcher-template-section', 'セクション', true);
	fetcherTemplateSection.placeholder = '随意指定';

	// Field for protection targets - Fetcher - By category
	var fetcherCategoryWrapper = document.createElement('div');
	fetcherCategoryWrapper.classList.add('mp-targets-fetcher-settings');
	fetcherCategoryWrapper.id = 'mp-targets-fetcher-category';
	fetcherWrapper.appendChild(fetcherCategoryWrapper);
	var fetcherCategoryTitle = createFetcherTextbox(fetcherCategoryWrapper, 'mp-targets-fetcher-category-title', 'カテゴリ');
	fetcherCategoryTitle.placeholder = '名前空間接頭辞を除いたページ名';
	var fetcherCategoryExclude = createFetcherTextbox(fetcherCategoryWrapper, 'mp-targets-fetcher-category-exclude', '除外ページ', true);
	var fetcherCategoryExcludeRegex = createFetcherRegexOption(fetcherCategoryWrapper, 'category-exclude', true);
	setRegexPlaceholder(fetcherCategoryExclude, fetcherCategoryExcludeRegex.regexMode);
	var fetcherCategoryNamespace = createFetcherTextbox(fetcherCategoryWrapper, 'mp-targets-fetcher-category-namespace', '名前空間', true);
	var nsTooltip = [
		'ノートは+1', '0: 標準', '2: 利用者', '4: Wikipedia', '6: ファイル', '8: MediaWiki',
		'10: Template', '12: Help', '14: Category', '100: Portal', '102: プロジェクト', '828: モジュール'
	].join('\n');
	fetcherCategoryNamespace.title = nsTooltip;
	$(fetcherCategoryNamespace).tooltip({ // Show tooltip when the namespace specifier textbox is hovered over
		tooltipClass: 'mp-tooltip',
		position: {
			my: 'left bottom',
			at: 'left top'
		}
	});
	fetcherCategoryNamespace.placeholder = '随意指定・パイプで分割';

	// Field for protection targets - Fetcher - By contribs
	var fetcherContribsWrapper = document.createElement('div');
	fetcherContribsWrapper.classList.add('mp-targets-fetcher-settings');
	fetcherContribsWrapper.id = 'mp-targets-fetcher-contribs';
	fetcherWrapper.appendChild(fetcherContribsWrapper);
	createFetcherLabel(fetcherContribsWrapper, '');
	var fetcherContribsDeleted = createLabeledCheckbox(fetcherContribsWrapper, 'mp-targets-fetcher-contribs-deleted', '削除された投稿記録', {checkboxCss: 'margin-left: 1em;'});
	var fetcherContribsUsername = createFetcherTextbox(fetcherContribsWrapper, 'mp-targets-fetcher-contribs-username', '利用者名', true);
	createFetcherLabel(fetcherContribsWrapper, '', true);
	var fetcherContribsIntersect = createLabeledCheckbox(fetcherContribsWrapper, 'mp-targets-fetcher-contribs-intersect', '利用者間の重複投稿記録', {checkboxCss: 'margin-left: 1em;'});
	fetcherContribsIntersect.addEventListener('change', function() {
		fetcherContribsUsername.placeholder = this.checked ? 'パイプで分割 (複数入力必須)' : '';
	});
	var fetcherContribsIntersectCountWrapper = document.createElement('span');
	fetcherContribsIntersectCountWrapper.id = 'mp-targets-fetcher-contribs-intersect-count';
	fetcherContribsIntersectCountWrapper.appendChild(document.createTextNode(' (重複数'));
	createTooltip(fetcherContribsIntersectCountWrapper,
		'「n利用者以上が共通して編集したページ」の n の値を指定します (2以上の整数)。未指定の場合、入力された利用者名数が自動的に適用されます。\n' +
		'例1: "A|B" (自動)指定数: 2\n' +
		'この場合、「利用者AとBの投稿記録内で2利用者以上が共通して編集したページ」、すなわちAとBの双方が編集したページのみを抽出します。\n' +
		'例2: "A|B|C|D" 指定数: 3\n' +
		'この場合、「利用者A, B, C, Dの投稿記録内で3利用者以上が共通して編集したページ」を抽出します。'
	);
	fetcherContribsIntersectCountWrapper.appendChild(document.createTextNode(': '));
	var fetcherContribsIntersectCount = document.createElement('input');
	fetcherContribsIntersectCount.style.width = '5em';
	fetcherContribsIntersectCount.type = 'number';
	fetcherContribsIntersectCount.min = '2';
	fetcherContribsIntersectCount.placeholder = '整数指定';
	fetcherContribsIntersectCountWrapper.appendChild(fetcherContribsIntersectCount);
	fetcherContribsIntersectCountWrapper.appendChild(document.createTextNode(')'));
	fetcherContribsWrapper.appendChild(fetcherContribsIntersectCountWrapper);
	var fetcherContribsExclude = createFetcherTextbox(fetcherContribsWrapper, 'mp-targets-fetcher-contribs-exclude', '除外ページ', true);
	var fetcherContribsExcludeRegex = createFetcherRegexOption(fetcherContribsWrapper, 'contribs-exclude', true);
	setRegexPlaceholder(fetcherContribsExclude, fetcherContribsExcludeRegex.regexMode);
	var fetcherContribsNamespace = createFetcherTextbox(fetcherContribsWrapper, 'mp-targets-fetcher-contribs-namespace', '名前空間', true);
	fetcherContribsNamespace.title = nsTooltip;
	$(fetcherContribsNamespace).tooltip({
		tooltipClass: 'mp-tooltip',
		position: {
			my: 'left bottom',
			at: 'left top'
		}
	});
	fetcherContribsNamespace.placeholder = '随意指定・パイプで分割';
	createFetcherLabel(fetcherContribsWrapper, '期間', true); // Addtional date specifier
	var fetcherContribsDateFrom = document.createElement('input');
	fetcherContribsDateFrom.type = 'text';
	fetcherContribsDateFrom.id = 'mp-targets-fetcher-contribs-datefrom';
	fetcherContribsDateFrom.style.width = '13em';
	fetcherContribsDateFrom.placeholder = 'この日時以降';
	fetcherContribsWrapper.appendChild(fetcherContribsDateFrom);
	fetcherContribsWrapper.appendChild(document.createTextNode(' ~ '));
	var fetcherContribsDateTo = document.createElement('input');
	fetcherContribsDateTo.type = 'text';
	fetcherContribsDateTo.id = 'mp-targets-fetcher-contribs-dateto';
	fetcherContribsDateTo.style.width = '13em';
	fetcherContribsDateTo.placeholder = 'この日時以前';
	fetcherContribsWrapper.appendChild(fetcherContribsDateTo);
	var fetcherContribsDateWarning = document.createElement('span');
	fetcherContribsDateWarning.id = 'mp-targets-fetcher-contribs-datewarning';
	fetcherContribsDateWarning.style.cssText = 'display: none; margin-left: 1em;';
	fetcherContribsDateWarning.innerHTML = '<b style="color:red;">!</b>「YYYY-MM-DD」のフォーマットで入力してください';
	fetcherContribsWrapper.appendChild(fetcherContribsDateWarning);
	var dateRegex = /^(2[01][0-9][0-9]-[0-2][0-9]-[0-3][0-9])?$/;
	[fetcherContribsDateFrom, fetcherContribsDateTo].forEach(function(el, i, arr) {
		var other = i === 0 ? arr[1] : arr[0];
		el.addEventListener('input', function() { // Show a warning message if an ill-formatted timestamp is typed into either of the textboxes
			fetcherContribsDateWarning.style.display = dateRegex.test(this.value) && dateRegex.test(other.value) ? 'none' : 'inline-block';
		});
		$(el).datepicker({ // Add datepicker
			dateFormat: 'yy-mm-dd',
			onSelect: function() { // Trigger input event when a date is selected
				el.dispatchEvent(new Event('input'));
			}
		});
	});

	// Field for protection targets - Fetcher - By links
	var fetcherLinkselectorWrapper = document.createElement('div');
	fetcherLinkselectorWrapper.classList.add('mp-targets-fetcher-settings');
	fetcherLinkselectorWrapper.id = 'mp-targets-fetcher-linkselector';
	fetcherWrapper.appendChild(fetcherLinkselectorWrapper);
	var fetcherLinkselector = createFetcherTextbox(fetcherLinkselectorWrapper, 'mp-targets-fetcher-linkselector-pagename', 'ページ');

	createFetcherLabel(fetcherLinkselectorWrapper, '', true);
	var fetcherLinkselectorUrlMode = createLabeledCheckbox(fetcherLinkselectorWrapper, 'mp-targets-fetcher-linkselector-urlmode', 'URLモード', {checkboxCss: 'margin-left: 1em;'});
	fetcherLinkselectorUrlMode.addEventListener('change', function() {
		if (this.checked) {
			fetcherLinkselector.placeholder = '"' + mw.config.get('wgServer') + mw.config.get('wgScript') + '?title="の後続部分';
		} else {
			fetcherLinkselector.placeholder = '';
		}
	});

	// Field for protection targets - Fetcher - Main button
	var fetcher = createButton(fetcherWrapper, 'mp-targets-fetcher-fetch', '取得', {buttonCss: 'margin-top: 0.5em;'});
	var fetcherResult = document.createElement('span');
	fetcherResult.id = 'mp-targets-fetcher-result';
	fetcherResult.style.cssText = 'display: inline-block; margin-left: 1em;';
	fetcherWrapper.appendChild(fetcherResult);

	var fetcherTimeout;
	/**
	 * Show a progress message for a fetcher request.
	 * @param {string} message
	 * @param {"doing"|"done"|"failed"|"cancelled"} imageType
	 * @param {boolean} autoHide
	 */
	var showFetcherResult = function(message, imageType, autoHide) {
		clearTimeout(fetcherTimeout);
		toggleDisableAttributes(imageType === 'doing');
		fetcherResult.innerHTML = message;
		fetcherResult.appendChild(getIcon(imageType));
		if (autoHide) {
			fetcherTimeout = setTimeout(function() {
				fetcherResult.innerHTML = '';
			}, 5000);
		}
	};

	/**
	 * Show the number of pages fetched when the fetching succeeds.
	 * @param {string[]} pagetitles
	 */
	var pagesFetched = function(pagetitles) {
		var curTitleList = cleanupPagetitles();
		var newTitleList = cleanupPagetitles({replace: curTitleList.titles.concat(pagetitles)});
		var diff = pagetitles.length - (newTitleList.titles.length - curTitleList.titles.length);
		var msg = pagetitles.length + 'ページ取得しました' + (diff ? ' (うち' + diff + 'ページは既に入力済みです)' : '');
		showFetcherResult(msg, 'done', true);
		tgtTab1.radio.checked = true;
	};

	/**
	 * Create a regex out of a string.
	 * @param {string} trimedStr Input string. An empty string should not be passed.
	 * @param {FetcherRegexOption} options An object of HTML elements that stores regex settings.
	 * @returns {RegExp|undefined} Returns undefined if regex conversion fails.
	 */
	var createFetcherRegex = function(trimedStr, options) {
		var flag = options.caseInsensitive.checked ? 'i' : '';
		if (options.regexMode.checked) { // If the input string is intended as a regex
			try {
				return new RegExp(trimedStr, flag);
			}
			catch (err) {
				showFetcherResult(err, 'failed', false);
				return;
			}
		} else { // If not
			var strArr = trimedStr.split('|').reduce(/** @param {string[]} acc */ function(acc, val) {
				val = val.trim();
				if (!val) return acc;
				val = mw.util.escapeRegExp(ucFirst(val.replace(/ /g, '_')));
				if (acc.indexOf(val) === -1) acc.push(val);
				return acc;
			}, []);
			return new RegExp('^(' + strArr.join('|') + ')$', flag);
		}
	};

	/**
	 * Create a string array of namespace numbers from a comma-divided plain text.
	 * @param {string} nsStr A plain string of namespace numbers divided by commas.
	 * @returns {string[]} Returns an empty array if the input string contains an invalid namespace number.
	 * In this case, showFetcherResult() is called internally.
	 */
	var createNamespaceNumberArray = function(nsStr) {

		var invalidNsNums = [];

		var namespacesArr = nsStr.split('|').reduce(/** @param {string[]} acc */ function(acc, num) {
			if (!num) return acc;
			if (/^([0-9]|1[0-5]|10[0-3]|82[89])$/.test(num)) {
				if (acc.indexOf(num) === -1) acc.push(num);
			} else {
				if (invalidNsNums.indexOf(num) === -1) invalidNsNums.push(num);
			}
			return acc;
		}, []);

		if (invalidNsNums.length) {
			showFetcherResult(invalidNsNums.join(', ') + 'は不正な名前空間です', 'failed', false);
			return [];
		} else if (!namespacesArr.length) {
			return ['*'];
		} else {
			return namespacesArr;
		}

	};

	// Event listner to run the fetcher
	fetcher.addEventListener('click', function() {

		switch (fetcherMode.value) {
			case 'テンプレート':
				(function() { // just for scope

					var title = fetcherTemplatePagetitle.value.replace(/\u200e/g, '').trim();
					if (!title) return showFetcherResult('ページ名は必須です', 'failed', true);

					var nameVal = fetcherTemplateTitle.value.replace(/\u200e/g, '').trim();
					if (!nameVal) return showFetcherResult('取得するテンプレート名を入力してください', 'failed', true);
					var templateName = createFetcherRegex(nameVal, fetcherTemplateTitleRegex);
					if (!templateName) return;

					showFetcherResult('取得しています', 'doing', false);

					var sectionName = fetcherTemplateSection.value.replace(/\u200e/g, '').trim();
					var params = {
						title: title,
						templateName: templateName,
						sectionName: sectionName ? sectionName : undefined
					};
					return getFirstTemplateParameters(params).then(function(pagetitles) {
						if (typeof pagetitles === 'string') {
							var err = pagetitles;
							showFetcherResult(err, 'failed', true);
						} else {
							pagesFetched(pagetitles);
						}
					});

				})();
				break;
			case 'カテゴリ':
				(function() {

					var title = fetcherCategoryTitle.value.replace(/\u200e/g, '').trim();
					if (!title) return showFetcherResult('カテゴリ名は必須です', 'failed', true);

					var excludeVal = fetcherCategoryExclude.value.replace(/\u200e/g, '').trim();
					/** @type {RegExp|undefined} */
					var excludeRegex;
					if (excludeVal) {
						excludeRegex = createFetcherRegex(excludeVal, fetcherCategoryExcludeRegex);
						if (!excludeRegex) return;
					}

					var namespaces = fetcherCategoryNamespace.value.replace(/[\u200e\s]/g, '');
					var namespacesArr = createNamespaceNumberArray(namespaces);
					if (!namespacesArr.length) return;

					showFetcherResult('取得しています', 'doing', false);
					return getCatMembers(title, namespacesArr).then(function(pagetitles) {
						// @ts-ignore excludeRegex can't be undefined
						if (excludeRegex) pagetitles = pagetitles.filter(function(el) { return !excludeRegex.test(el); });
						if (!pagetitles.length) {
							showFetcherResult('ページが見つかりませんでした', 'failed', true);
						} else {
							pagesFetched(pagetitles);
						}
					});

				})();
				break;
			case '投稿記録':
				(function() {

					var intersect = fetcherContribsIntersect.checked;
					/** @type {string[]} */
					var usernames;
					/** @type {number} */
					var intersectCountInt;

					if (intersect) {

						var someIsCidr = false;
						var usernamesClean = fetcherContribsUsername.value.replace(/\u200e/g, '').trim();
						if (!usernamesClean) return showFetcherResult('利用者は必須です', 'failed', true);
						var overlapCount = 0;
						usernames = usernamesClean.split('|').reduce(/** @param {string[]} acc */ function(acc, user) {
							user = user.trim().replace(/ /g, '_');
							if (user && acc.indexOf(user) === -1) {
								acc.push(user);
							} else {
								overlapCount++;
							}
							if (!someIsCidr && mw.util.isIPAddress(user, true) && !mw.util.isIPAddress(user)) {
								someIsCidr = true;
							}
							return acc;
						}, []);
						if (usernames.length <= 1) {
							return showFetcherResult('複数の利用者名を指定してください', 'failed', true);
						}
						if (overlapCount) {
							return showFetcherResult('利用者名が重複しています', 'failed', true);
						}
						if (fetcherContribsDeleted.checked && someIsCidr) { // CIDR's deleted contribs can't be fetched
							return showFetcherResult('IPレンジの削除された投稿記録は取得できません', 'failed', true);
						}
						var intersectCount = fetcherContribsIntersectCount.value || usernames.length.toString();
						if (/^\d+$/.test(intersectCount) && (intersectCountInt = parseInt(intersectCount)) > 1) {
							if (intersectCountInt > usernames.length) {
								return showFetcherResult('指定された利用者数よりも多い重複数が指定されています', 'failed', true);
							}
						} else {
							return showFetcherResult('重複数の値が不正です', 'failed', true);
						}

					} else {
						usernames = [fetcherContribsUsername.value.replace(/\u200e/g, '').trim().replace(/ /g, '_')];
						if (!usernames[0]) return showFetcherResult('利用者は必須です', 'failed', true);
						if (fetcherContribsDeleted.checked && mw.util.isIPAddress(usernames[0], true) && !mw.util.isIPAddress(usernames[0])) {
							return showFetcherResult('IPレンジの削除された投稿記録は取得できません', 'failed', true);
						}
					}

					var excludeVal = fetcherContribsExclude.value.replace(/\u200e/g, '').trim();
					/** @type {RegExp|undefined} */
					var excludeRegex;
					if (excludeVal) {
						excludeRegex = createFetcherRegex(excludeVal, fetcherContribsExcludeRegex);
						if (!excludeRegex) return;
					}

					var namespaces = fetcherContribsNamespace.value.replace(/[\u200e\s]/g, '');
					var namespacesArr = createNamespaceNumberArray(namespaces);
					if (!namespacesArr.length) return;

					var tsFrom = fetcherContribsDateFrom.value.replace(/[\u200e\s]/g, '');
					var tsTo = fetcherContribsDateTo.value.replace(/[\u200e\s]/g, '');
					var /** @type {Date} */ dFrom,
						/** @type {Date} */ dTo;
					try {
						if (tsFrom) dFrom = new Date(tsFrom);
					}
					catch (err) {
						return showFetcherResult(err, 'failed', true);
					}
					try {
						if (tsTo) dTo = new Date(tsTo);
					}
					catch (err) {
						return showFetcherResult(err, 'failed', true);
					}
					// @ts-ignore
					if (dFrom && dTo && dFrom > dTo) { // If the from date is after the to date
						dFrom = new Date(tsTo);
						dTo = new Date(tsFrom);
					}
					var /** @type {string|undefined} */ isoFrom,
						/** @type {string|undefined} */ isoTo;
					// @ts-ignore
					if (dFrom) {
						isoFrom = dFrom.toJSON();
					}
					// @ts-ignore
					if (dTo) {
						dTo.setDate(dTo.getDate() + 1);
						dTo.setSeconds(dTo.getSeconds() - 1);
						isoTo = dTo.toJSON();
					}

					showFetcherResult('取得しています', 'doing', false);
					var takingTime = setTimeout(function() {
						mw.notify('取得に時間が掛かっています...');
					}, 10000);
					var query = fetcherContribsDeleted.checked ? getDeletedUserContribs : getUserContribs;
					var deferreds = [];
					usernames.forEach(function(user) {
						deferreds.push(query(user, namespacesArr, isoFrom, isoTo));
					});
					return $.when.apply($, deferreds).then(function() {

						clearTimeout(takingTime);

						/** @type {string[]} */
						var pagetitles = [];
						var args = arguments;
						/**
						 * @type {Object.<string, number>}
						 * pagetitle-occurrence count pairs
						 */
						var pageCount = {};

						for (var i = 0; i < args.length; i++) {
							/** @type {string[]|undefined} */
							var pagetitlesArr = args[i];
							if (!pagetitlesArr) { // If undefined, a query failed
								return showFetcherResult('取得に失敗しました', 'failed', true);
							} else if (intersect) {
								pagetitlesArr.forEach(function(title, j, arr) {
									if (arr.indexOf(title) !== j) { // Don't look at duplicates in the same array
										return;
									}
									if (!(excludeRegex && excludeRegex.test(title))) {
										if (pageCount[title]) {
											pageCount[title]++;
										} else {
											pageCount[title] = 1;
										}
									}
								});
								pagetitles = Object.keys(pageCount).reduce(/** @param {string[]} acc */ function(acc, title) {
									var cnt = pageCount[title];
									if (cnt >= intersectCountInt && acc.indexOf(title) === -1) {
										acc.push(title);
									}
									return acc;
								}, []);
							} else {
								pagetitles = pagetitlesArr.filter(function(el) {
									return excludeRegex ? !excludeRegex.test(el) : true;
								});
							}
						}

						if (!pagetitles.length) {
							showFetcherResult('ページが見つかりませんでした', 'failed', true);
						} else {
							pagesFetched(pagetitles);
						}

					});

				})();
				break;
			case 'リンク選択':
				(function() {

					var title = fetcherLinkselector.value.replace(/\u200e/g, '').trim();
					if (!title) return showFetcherResult('ページ名は必須です', 'failed', true);

					var getHtml = function() {
						if (fetcherLinkselectorUrlMode.checked || getNamespaceNumber(title) === -1) {
							return scrapeWikipage(title);
						} else {
							return read(title, true);
						}
					};

					showFetcherResult('取得しています', 'doing', false);
					return getHtml().then(function(html) {

						if (html === null) {
							showFetcherResult('ページが見つかりませんでした', 'failed', true);
							return;
						} else if (html === undefined) {
							showFetcherResult('ページコンテンツの取得に失敗しました', 'failed', true);
							return;
						}
						linkSelector(html).then(function(pagetitles) {
							if (!pagetitles.length) {
								showFetcherResult('ページが選択されませんでした', 'failed', true);
							} else {
								pagesFetched(pagetitles);
							}
						});

					});

				})();
				break;
			default:
				showFetcherResult('バグが発生しました', 'failed', true);
		}

	});

	// Field for protection settings
	var settingWrapper = document.createElement('fieldset');
	settingWrapper.id = 'mp-settings';
	settingWrapper.innerHTML = '<legend>処理設定</legend>';
	container.appendChild(settingWrapper);

	/**
	 * Valid action values for action=protect.
	 * @typedef ProtectLevelActions
	 * @type {"edit"|"move"|"upload"|"create"}
	 */
	/**
	 * Valid user group values for action=protect.
	 * @typedef ProtectLevelGroups
	 * @type {"all"|"autoconfirmed"|"extendedconfirmed"|"sysop"}
	 */
	/**
	 * An object of protection user group and dropdown option pairs.
	 * @typedef ProtectLevelGroupsOptions
	 * @type {Record<ProtectLevelGroups, HTMLOptionElement>}
	 */
	/**
	 * Valid expiry values for action=protect.
	 * @typedef ProtectLevelExpiries
	 * @type {"indefinite"|"1 week"|"2 weeks"|"1 month"|"3 months"|"6 months"|"1 year"|"3 years"|""}
	 */
	/**
	 * An object of expiry value and dropdown option pairs.
	 * @typedef ProtectLevelExpiriesOptions
	 * @type {Record<ProtectLevelExpiries, HTMLOptionElement>}
	 */

	/**
	 * Level class. Creates a fieldset with dropdowns and a textbox to select a protection level and expiry.
	 * @class
	 * @constructor
	 * @param {string} legendLabel
	 * @param {ProtectLevelActions} action
	 */
	var Level = function(legendLabel, action) {

		var self = this;
		this.action = action;

		// Wrapper fieldset
		var wrapper = document.createElement('fieldset');
		var id = 'mp-level-' + action;
		wrapper.id = id;
		wrapper.style.marginTop = '0';
		wrapper.innerHTML = '<legend>' + legendLabel + '</legend>';
		settingWrapper.appendChild(wrapper);
		this.wrapper = wrapper;

		// Level selector dropdown
		this.level = createLabeledDropdown(wrapper, id + '-select', 'レベル', {labelCss: 'width: 7em;'});
		/** @type {Record<ProtectLevelGroups, string>} */
		var groupOptions = {
			all: 'すべての利用者に許可',
			autoconfirmed: '自動承認された利用者のみ許可',
			extendedconfirmed: '拡張承認された利用者と管理者に許可',
			sysop: '管理者のみ許可'
		};
		/** @type {ProtectLevelGroupsOptions} */
		this.groupOptions = Object.keys(groupOptions).reduce(/** @param {ProtectLevelGroupsOptions} acc */ function(acc, group) {
			var opt = document.createElement('option');
			opt.value = group;
			opt.textContent = groupOptions[group];
			self.level.add(opt);
			acc[group] = opt;
			return acc;
		}, Object.create(null));

		// Expiry selector dropdown
		this.expiry = createLabeledDropdown(wrapper, id + '-expiry', '期間', {labelCss: 'width: 7em;', appendBr: true});
		this.expiry.style.width = this.level.offsetWidth + 'px';
		var expiryOptions = {
			'デフォルトタイム': {
				indefinite: '無期限'
			},
			'プリセットタイム': {
				'1 week': '1週間',
				'2 weeks': '2週間',
				'1 month': '1ヵ月',
				'3 months': '3ヵ月',
				'6 months': '6ヵ月',
				'1 year': '1年',
				'3 years': '3年'
			},
			'その他の期間': {
				'': 'その他の期間'
			}
		};
		/** @type {ProtectLevelExpiriesOptions} */
		this.expiryOptions = Object.keys(expiryOptions).reduce(/** @param {ProtectLevelExpiriesOptions} acc */ function(acc, optgroupLabel) {
			var expiryObj = expiryOptions[optgroupLabel];
			var optgroup = document.createElement('optgroup');
			optgroup.label = optgroupLabel;
			Object.keys(expiryObj).forEach(function(exp) {
				var opt = document.createElement('option');
				opt.value = exp;
				opt.textContent = expiryObj[exp];
				optgroup.appendChild(opt);
				acc[exp] = opt;
			});
			self.expiry.add(optgroup);
			return acc;
		}, Object.create(null));

		// Custom expiry input
		this.custom = createLabeledTextbox(wrapper, id + '-expirycustom', 'その他の期間', {labelCss: 'width: 7em;', appendBr: true});
		this.custom.style.width = this.level.offsetWidth + 'px';
		this.custom.addEventListener('focus', function() { // Select 'other' in the expiry dropdown when the custom expiry input is focused
			self.expiryOptions[''].selected = true;
			self.expiry.dispatchEvent(new Event('change'));
		});

		// When a preset expiry is selected, clear the custom expiry input
		this.expiry.addEventListener('change', function() {
			if (this.value !== '') {
				self.custom.value = '';
				self.custom.dispatchEvent(new Event('input'));
			}
		});

	};

	/**
	 * Get the level dropdown value.
	 * @returns {ProtectLevelGroups}
	 * @method
	 */
	Level.prototype.getLevel = function() {
		// @ts-ignore
		return this.level.value;
	};

	/**
	 * Select a value in the level dropdown. Unaffected by disabled states.
	 * @param {ProtectLevelGroups} val
	 * @method
	 */
	Level.prototype.setLevel = function(val) {
		if (this.level.disabled) {
			this.level.disabled = false;
			this.groupOptions[val].selected = true;
			this.level.disabled = true;
		} else {
			this.groupOptions[val].selected = true;
		}
	};

	/**
	 * Get the value of the expiry dropdown.
	 * @method
	 */
	Level.prototype.getExpiry = function() {
		return this.expiry.value;
	};

	/**
	 * Select a value in the expiry dropdown. Unaffected by disabled states.
	 * @param {ProtectLevelExpiries} val
	 * @method
	 */
	Level.prototype.setExpiry = function(val) {
		if (this.expiry.disabled) {
			this.expiry.disabled = false;
			this.expiryOptions[val].selected = true;
			this.expiry.disabled = true;
		} else {
			this.expiryOptions[val].selected = true;
		}
	};

	/**
	 * Get the value of the custom expiry textbox.
	 * @method
	 */
	Level.prototype.getCustomExpiry = function() {
		return this.custom.value.trim();
	};

	/**
	 * Set a value to the custom expiry textbox. Unaffected by disabled states.
	 * @param {string} val
	 * @method
	 */
	Level.prototype.setCustomExpiry = function(val) {
		if (this.custom.disabled) {
			this.custom.disabled = false;
			this.custom.value = val;
			this.custom.disabled = true;
		} else {
			this.custom.value = val;
		}
	};

	/**
	 * Get an expiry value either from the dropdown or the textbox, depending on what's selected in the former.
	 * @method
	 */
	Level.prototype.getCleanExpiry = function() {
		return this.expiryOptions[''].selected ? this.custom.value.trim() : this.expiry.value;
	};

	/**
	 * Enable all the dropdowns and the textbox.
	 * @method
	 */
	Level.prototype.enable = function() {
		[this.level, this.expiry, this.custom].forEach(function(el) {
			el.disabled = false;
		});
	};

	/**
	 * Disable all the dropdowns and the textbox.
	 * @method
	 */
	Level.prototype.disable = function() {
		[this.level, this.expiry, this.custom].forEach(function(el) {
			el.disabled = true;
		});
	};

	/**
	 * Synchronize the dropdown and textbox values with another LevelObject.
	 * @param {Level} LevelObject
	 * @param {() => boolean} [conditionPredicate] Trigger sync only when matching this condition predicate
	 */
	Level.prototype.sync = function(LevelObject, conditionPredicate) {
		var self = this;
		LevelObject.level.addEventListener('change', function() {
			if (conditionPredicate ? conditionPredicate() : true) {
				if (self.level.disabled) {
					self.level.disabled = false;
					// @ts-ignore
					self.setLevel(this.value);
					self.level.disabled = true;
				} else {
					// @ts-ignore
					self.setLevel(this.value);
				}
			}
		});
		LevelObject.expiry.addEventListener('change', function() {
			if (conditionPredicate ? conditionPredicate() : true) {
				if (self.expiry.disabled) {
					self.expiry.disabled = false;
					// @ts-ignore
					self.setExpiry(this.value);
					self.expiry.disabled = true;
				} else {
					// @ts-ignore
					self.setExpiry(this.value);
				}
			}
		});
		LevelObject.custom.addEventListener('input', function() {
			if (conditionPredicate ? conditionPredicate() : true) {
				if (self.custom.disabled) {
					self.custom.disabled = false;
					self.setCustomExpiry(this.value);
					self.custom.disabled = true;
				} else {
					self.setCustomExpiry(this.value);
				}
			}
		});
	};

	// Create level selectors
	var existingPageProtection = createLabeledCheckbox(settingWrapper, 'mp-settings-existingpage', '既存ページの保護');
	existingPageProtection.checked = true;

	var editProtection = new Level('編集保護', 'edit');

	var unlockAdditionalProtection = createLabeledCheckbox(settingWrapper, 'mp-settings-unlock', '追加保護オプションのロックを解除');

	var moveProtection = new Level('移動保護', 'move');
	moveProtection.disable();
	var conditionPredicate = function() { return !unlockAdditionalProtection.checked; };
	moveProtection.sync(editProtection, conditionPredicate);

	var uploadProtection = new Level('アップロード保護', 'upload');
	uploadProtection.disable();
	uploadProtection.sync(editProtection, conditionPredicate);

	existingPageProtection.addEventListener('change', function() { // Enable/disable the level fields for existing page protection
		var self = this;
		unlockAdditionalProtection.disabled = !self.checked;
		[editProtection, moveProtection, uploadProtection].forEach(function(LevelObject, i) {
			if (!unlockAdditionalProtection.checked && self.checked) {
				if (i === 0) LevelObject.enable();
			} else if (self.checked) {
				LevelObject.enable();
			} else {
				LevelObject.disable();
			}
		});
	});

	unlockAdditionalProtection.addEventListener('change', function() {
		var self = this;
		[moveProtection, uploadProtection].forEach(function(LevelObject) {
			if (self.checked) {
				LevelObject.enable();
			} else {
				LevelObject.setLevel(editProtection.getLevel());
				// @ts-ignore
				LevelObject.setExpiry(editProtection.getExpiry());
				LevelObject.setCustomExpiry(editProtection.getCustomExpiry());
				LevelObject.disable();
			}
		});
	});

	var missingPageProtection = createLabeledCheckbox(settingWrapper, 'mp-settings-missingpage', '未作成ページの保護');
	var createProtection = new Level('作成保護', 'create');
	createProtection.disable();
	missingPageProtection.addEventListener('change', function() { // Enable/disable the level fields for missing page protection
		if (this.checked) {
			createProtection.enable();
		} else {
			createProtection.disable();
		}
	});

	/**
	 * @typedef ActionProtectFragmentParams
	 * @type {{protections: string[]; expiry: string[]; unprotect: boolean;}|undefined}
	 */
	/**
	 * Get level settings. The object properties are undefined if protection is disabled for the relevant type of pages.
	 * @returns {{missing: ActionProtectFragmentParams; existing: ActionProtectFragmentParams; existingFiles: ActionProtectFragmentParams;}}
	 */
	var getLevels = function() {

		/** @type {ActionProtectFragmentParams} */
		var missing;
		/** @type {ActionProtectFragmentParams} */
		var existing;
		/** @type {ActionProtectFragmentParams} */
		var existingFiles;

		// Counters to check for unprotect only options
		var existingPagesUnprotectCount = 0;
		var existingFilesUnprotectCount = 0;

		[editProtection, moveProtection, uploadProtection, createProtection].forEach(function(levelObj) {

			var dependentLevelObj = ['move', 'upload'].indexOf(levelObj.action) !== -1 && !unlockAdditionalProtection.checked ?
						editProtection :
						levelObj;
			var action = levelObj.action;
			var level = dependentLevelObj.getLevel();
			var expiry = dependentLevelObj.getCleanExpiry();

			if (action === 'create') {
				if (missingPageProtection.checked) {
					missing = {
						protections: [action + '=' + level],
						expiry: [expiry],
						unprotect: level === 'all'
					};
				}
			} else {
				if (level === 'all') {
					existingFilesUnprotectCount++;
					if (action !== 'upload') {
						existingPagesUnprotectCount++;
					}
				}
				if (existingPageProtection.checked) {
					if (action !== 'upload') {
						if (!existing) {
							existing = {
								protections: [],
								expiry: [],
								unprotect: false
							};
						}
						existing.protections.push(action + '=' + level);
						existing.expiry.push(expiry);
					}
					if (!existingFiles) {
						existingFiles = {
							protections: [],
							expiry: [],
							unprotect: false
						};
					}
					existingFiles.protections.push(action + '=' + level);
					existingFiles.expiry.push(expiry);
				}
			}

		});
		if (existing) {
			existing.unprotect = existingPagesUnprotectCount === 2;
		}
		if (existingFiles) {
			existingFiles.unprotect = existingFilesUnprotectCount === 3;
		}

		return {
			missing: missing,
			existing: existing,
			existingFiles: existingFiles
		};

	};

	// Reasons
	var reasonWrapper = document.createElement('fieldset');
	reasonWrapper.id = 'mp-settings-reason';
	reasonWrapper.style.marginTop = '0';
	reasonWrapper.innerHTML = '<legend>保護理由</legend>';
	settingWrapper.appendChild(reasonWrapper);
	var reasonLabelCss = {labelCss: 'width: 7em;'};
	var reasonLabelCssBr = {labelCss: 'width: 7em;', appendBr: true};
	var rsnDefaultOption =
		'<optgroup label="その他の理由">' +
			'<option value="">その他の理由</option>' +
		'</optgroup>';
	var settingRsn1Dropdown = createLabeledDropdown(reasonWrapper, 'mp-settings-reason-1', '理由1', reasonLabelCss);
	settingRsn1Dropdown.innerHTML = rsnDefaultOption;
	var settingRsn2Dropdown = createLabeledDropdown(reasonWrapper, 'mp-settings-reason-2', '理由2', reasonLabelCssBr);
	settingRsn2Dropdown.innerHTML = rsnDefaultOption;
	var settingRsnCInput = createLabeledTextbox(reasonWrapper, 'mp-settings-reason-C', '', reasonLabelCssBr);
	settingRsnCInput.placeholder = '非定形理由';
	settingRsnCInput.style.cssText = 'margin-bottom: 0.5em; width: 32em;';
	getProtectReasonDropdown().then(function(dropdown) {
		if (!dropdown) {
			settingRsn1Dropdown.style.width = '32em';
			settingRsn2Dropdown.style.width = '32em';
			return alert('保護理由の取得に失敗しました。');
		}
		settingRsn1Dropdown.innerHTML = dropdown.innerHTML;
		settingRsn2Dropdown.innerHTML = dropdown.innerHTML;
		if (settingRsn1Dropdown.offsetWidth >= settingRsnCInput.offsetWidth) {
			settingRsnCInput.style.width = settingRsn1Dropdown.offsetWidth.toString() + 'px';
		} else {
			settingRsn1Dropdown.style.width = '32em';
			settingRsn2Dropdown.style.width = '32em';
		}
	});
	getAutocompleteSource().then(function(list) { // Get a VIP/LTA list and set its items as autocomplate condidates
		if (!list.length) return;
		$(settingRsnCInput).autocomplete({
			source: function(req, res) { // Limit the list to the maximum number of 10, or the list can stick out of the viewport
				var results = $.ui.autocomplete.filter(list, req.term);
				res(results.slice(0, 10));
			},
			position: {
				my: 'left bottom',
				at: 'left top'
			}
		});
		settingRsnCInput.placeholder = '非定型理由 (VIP・LTA名称を入力すると候補が表示されます)';
	});

	var getReasons = function() {
		var reasonsArr = [settingRsn1Dropdown.value, settingRsn2Dropdown.value, settingRsnCInput.value.trim()];
		return reasonsArr.filter(function(el) { return el; }).join(': ');
	};

	// Watchlist checkbox
	var settingWatch = createLabeledCheckbox(settingWrapper, 'mp-settings-watch', '保護対象ページをウォッチリストに追加', {wrapDiv: true});
	settingWatch.addEventListener('change', function() {
		if (this.checked) this.scrollIntoView({behavior: 'smooth'});
	});
	var settingWatchExpiryUl = document.createElement('ul');
	settingWatchExpiryUl.id = 'mp-settings-watch-expiry-container';
	settingWatchExpiryUl.style.marginTop = '0';
	settingWatchExpiryUl.style.listStyle = 'none';
	// @ts-ignore
	settingWatch.parentElement.appendChild(settingWatchExpiryUl);
	var settingWatchExpiryLi = document.createElement('li');
	settingWatchExpiryUl.appendChild(settingWatchExpiryLi);
	var settingWatchExpiry = createLabeledDropdown(settingWatchExpiryLi, 'mp-settings-watch-expiry', '期間', {labelCss: 'width: 3em;', dropdownCss: 'margin-right: 1em;'});
	settingWatchExpiry.innerHTML =
			'<option value="indefinite">無期限</option>' +
			'<option value="1 week">1週間</option>' +
			'<option value="1 month">1か月</option>' +
			'<option value="3 months">3か月</option>' +
			'<option value="6 months">6か月</option>' +
			'<option value="1 year">1年</option>';
	var settingWatchRelativeExpiry = createLabeledCheckbox(settingWatchExpiryLi, 'mp-settings-watch-expiry-relative', '保護期間満了時から換算', {wrapDiv: true});
	/** @type {HTMLDivElement} */
	// @ts-ignore
	var settingWatchRelativeExpiryWrapper = settingWatchRelativeExpiry.parentElement;
	createTooltip(settingWatchRelativeExpiryWrapper, // Maximum watchlist expiry is 1 year; show tooltip for this
		'保護解除時は、ドロップダウンで選択した期間設定がそのまま適用されます。' +
		'相対期間ウォッチリスト登録に限らず、最長のウォッチ期間はAPIの制限により1年です。これを超過する場合、無期限でウォッチリストに登録されます。'
	);
	var settingWatchExcludeUnprotect = createLabeledCheckbox(settingWatchExpiryLi, 'mp-settings-watch-excludeunprotect', '保護解除時は適用しない', {wrapDiv: true});
	settingWatchExcludeUnprotect.checked = true;

	// Cascade protection checkbox
	var settingCascade = createLabeledCheckbox(settingWrapper, 'mp-settings-cascade', 'カスケード保護', {wrapDiv: true});

	// Remove-RFP-template checkbox
	var settingRmRFPTemplates = createLabeledCheckbox(settingWrapper, 'mp-settings-rmrfp', '保護依頼タグを自動除去', {wrapDiv: true});
	settingRmRFPTemplates.checked = true;

	var settingRmPpTemplates = createLabeledCheckbox(settingWrapper, 'mp-settings-rmpp', '保護解除時に保護タグを自動除去', {wrapDiv: true});
	settingRmPpTemplates.checked = true;

	// Add-Pp-template checkbox
	var settingPpTemplatesLabel = '保護タグを自動添付 (<a href="/wiki/Template:Pp#関連項目" target="_blank">一覧</a>)';
	var settingPpTemplates = createLabeledCheckbox(settingWrapper, 'mp-settings-addpp', settingPpTemplatesLabel, {wrapDiv: true});
	settingPpTemplates.addEventListener('change', function() {
		if (this.checked) {
			settingRmRFPTemplates.checked = true;
			settingRmRFPTemplates.disabled = true;
			this.scrollIntoView({behavior: 'smooth'});
		} else {
			settingRmRFPTemplates.disabled = false;
		}
	});

	// Add-Pp-template checkbox - template selector
	var settingPpTemplatesUl = document.createElement('ul');
	settingPpTemplatesUl.id = 'mp-settings-addpp-selector';
	settingPpTemplatesUl.style.marginTop = '0';
	// @ts-ignore
	settingPpTemplates.parentElement.appendChild(settingPpTemplatesUl);

	/**
	 * PpDropdown class. Creates a pp-argument selector dropdown.
	 * @class
	 * @constructor
	 * @param {string} dropdownId
	 * @param {string} dropdownLabel
	 * @param {string[]} optionNames Name option elements with these names.
	 * @param {(arrayElement: string, arrayElementIndex: number, createdOption: HTMLOptionElement) => void} [optionPredicate]
	 * Initialize the object instance in accordance with this predicate.
	 */
	var PpDropdown = function(dropdownId, dropdownLabel, optionNames, optionPredicate) {

		var li = document.createElement('li');
		settingPpTemplatesUl.appendChild(li);

		var self = this;
		this.dropdown = createLabeledDropdown(li, dropdownId, dropdownLabel, {labelCss: 'width: 7em;', dropdownCss: 'width: 10em;'});
		/** An object of option name and option element pairs. */
		this.options = optionNames.reduce(/** @param {Object.<string, HTMLOptionElement>} acc */ function(acc, name, i) {
			var opt = document.createElement('option');
			opt.textContent = name;
			if (optionPredicate) {
				optionPredicate(name, i, opt);
			}
			self.dropdown.add(opt);
			acc[name] = opt;
			return acc;
		}, Object.create(null));

	};

	/**
	 * Reset the dropdown by enabling it and unhiding all its options.
	 * @method
	 */
	PpDropdown.prototype.reset = function() {
		this.dropdown.disabled = false;
		this.dropdown.selectedIndex = 0;
		var self = this;
		Object.keys(this.options).forEach(function(optionName) {
			self.options[optionName].hidden = false;
		});
	};

	/**
	 * Select a dropdown option.
	 * @param {string} target
	 * @method
	 */
	PpDropdown.prototype.select = function(target) {
		if (this.options[target]) {
			this.options[target].selected = true;
		} else {
			throw new Error('The value "' + target + '" does not exist in the dropdown options.');
		}
	};

	/**
	 * Hide a dropdown option.
	 * @param {string} target
	 * @method
	 */
	PpDropdown.prototype.hide = function(target) {
		if (this.options[target]) {
			this.options[target].hidden = true;
		} else {
			throw new Error('The value "' + target + '" does not exist in the dropdown options.');
		}
	};

	/**
	 * Disable the dropdown.
	 * @method
	 */
	PpDropdown.prototype.disable = function() {
		this.dropdown.disabled = true;
	};

	/**
	 * Get the selected value of the dropdown.
	 * @method
	 */
	PpDropdown.prototype.val = function() {
		return this.dropdown.value;
	};

	// Create pp-argument selector dropdowns
	var ppTemplateOptions = [
		'pp', 'pp-dispute', 'pp-vandalism', 'pp-semi-indef', 'pp-template',
		'pp-permanent', 'pp-move', 'pp-move-vandalism', 'pp-move-dispute', 'pp-office',
		'pp-reset', 'pp-office-dmca'
	];
	var ppTemplates = new PpDropdown('mp-settings-addpp-selector-pps', 'テンプレート', ppTemplateOptions, function(el, i, opt) {
		if (i === 2) opt.selected = true; // Set initial value, pp-vandalism
	});

	var ppTypeOptions = ['', 'action=edit', 'action=move', 'action=upload'];
	var ppTypes = new PpDropdown('mp-settings-addpp-selector-types', '種別', ppTypeOptions, function(el, i, opt) {
		if (i == 0) opt.selected = true;
		if (i > 0) opt.value = '|' + el;
	});
	ppTypes.hide('action=upload'); // For pp-vandalism

	var ppSmallOptions = ['', 'small=yes', 'small=no'];
	var ppSmall = new PpDropdown('mp-settings-addpp-selector-small', '縮小表示', ppSmallOptions, function(el, i, opt) {
		if (i == 1) opt.selected = true;
		if (i > 0) opt.value = '|' + el;
	});
	ppSmall.hide('small=no'); // For pp-vandalism

	/**
	 * Get the Pp template option.
	 * @returns {PpOptions}
	 */
	var getPpOptions = function() {

		/** @type {false|string} */
		var addPp = false;
		if (settingPpTemplates.checked) {
			var pp = [ppTemplates.val(), ppTypes.val(), ppSmall.val()].filter(function(el) { return el; });
			addPp = '{{' + pp.join('') + '}}';
		}

		return {
			addPp: addPp,
			removePp: settingRmPpTemplates.checked,
			removeRfp: settingRmRFPTemplates.checked
		};

	};

	ppTemplates.dropdown.addEventListener('change', function() {

		/*******************************************************************************************\
			Arguments of [[Template:Pp]] (def: default value when not specified: should be hidden)
			template		action					small
			pp				edit, move, upload		def=no
			dispute			edit, move, upload		X
			vandalism		edit, move				def=no
			semi-indef		X						def=yes
			template		X						def=yes
			permanent		X						def=yes
			move			X						def=no
			move-vandalism	X						def=no
			move-dispute	X						X
			office			X						X
			reset			X						X
			office-dmca		X						X
		\*******************************************************************************************/

		// Reset
		ppTypes.reset();
		ppSmall.reset();

		switch (this.options[this.selectedIndex].text) {
			case 'pp':
				ppSmall.hide('small=no');
				break;
			case 'pp-dispute':
				ppSmall.disable();
				break;
			case 'pp-vandalism':
				ppTypes.hide('action=upload');
				ppSmall.hide('small=no');
				ppSmall.select('small=yes');
				break;
			case 'pp-semi-indef':
			case 'pp-template':
			case 'pp-permanent':
				ppTypes.disable();
				ppSmall.hide('small=yes');
				break;
			case 'pp-move':
			case 'pp-move-vandalism':
				ppTypes.disable();
				ppSmall.hide('small=no');
				break;
			case 'pp-move-dispute':
			case 'pp-office':
			case 'pp-reset':
			case 'pp-office-dmca':
				ppTypes.disable();
				ppSmall.disable();
		}

	});

	// Confirm-to-overwrite checkbox
	var settingConfirmToOverwrite = createLabeledCheckbox(settingWrapper, 'mp-settings-confirmtooverwrite', '保護の上書き前に確認メッセージを表示', {wrapDiv: true});
	settingConfirmToOverwrite.checked = true;
	settingConfirmToOverwrite.addEventListener('change', function() {
		if (this.checked) this.scrollIntoView({behavior: 'smooth'});
	});
	/** @type {HTMLDivElement} */
	// @ts-ignore
	var settingConfirmToOverwriteWrapper = settingConfirmToOverwrite.parentElement;

	// Confirm-to-unprotect checkbox
	var settingConfirmToOverwriteUl = document.createElement('ul');
	settingConfirmToOverwriteUl.id = 'mp-settings-confirmtounprotect-container';
	settingConfirmToOverwriteUl.style.marginTop = '0';
	settingConfirmToOverwriteUl.style.listStyle = 'none';
	settingConfirmToOverwriteWrapper.appendChild(settingConfirmToOverwriteUl);
	var settingConfirmToOverwriteLi = document.createElement('li');
	settingConfirmToOverwriteUl.appendChild(settingConfirmToOverwriteLi);
	var settingConfirmToUnprotect = createLabeledCheckbox(settingConfirmToOverwriteLi, 'mp-settings-confirmtounprotect', '保護解除前に確認メッセージを表示');
	settingConfirmToUnprotect.checked = true;
	createTooltip(settingConfirmToOverwriteLi, '意図しない保護解除対策です。意図をもって保護解除のレベル設定をしている場合はチェックを外してください。');

	// Field for the 'execute' button
	var massProtectWrapper = document.createElement('div');
	massProtectWrapper.id = 'mp-massprotect-container';
	massProtectWrapper.style.marginTop = '0.5em';
	container.appendChild(massProtectWrapper);

	var massProtectButton = createButton(massProtectWrapper, 'mp-massprotect', '実行', {buttonCss: 'display: block;'});
	var massProtectMessage = document.createElement('span');
	massProtectMessage.style.marginTop = '0.5em';
	massProtectMessage.style.display = 'block';
	massProtectWrapper.appendChild(massProtectMessage);

	/**
	 * An alternative for window.confirm by a link.
	 * @param {string} confirmMsg An innerHTML for massProtectMessage. Anchor buttons follow this after a line break.
	 * @returns {JQueryPromise<boolean>}
	 */
	var linkConfirm = function(confirmMsg) {
		var def = $.Deferred();

		massProtectMessage.innerHTML = '';
		massProtectMessage.innerHTML = confirmMsg + '<br>';

		var msgYes = document.createElement('a');
		msgYes.role = 'button';
		msgYes.textContent = '続行';
		msgYes.addEventListener('click', function() {
			massProtectMessage.innerHTML = '';
			def.resolve(true);
		});
		massProtectMessage.appendChild(msgYes);
		massProtectMessage.appendChild(document.createTextNode('・'));

		var msgNo = document.createElement('a');
		msgNo.role = 'button';
		msgNo.textContent = '中止';
		msgNo.addEventListener('click', function() {
			massProtectMessage.innerHTML = '';
			def.resolve(false);
		});
		massProtectMessage.appendChild(msgNo);

		massProtectMessage.scrollIntoView({behavior: 'smooth'});

		return def.promise();
	};

	// Execute MassProtect when the button is hit
	massProtectButton.addEventListener('click', function() {

		// Get levels
		var levels = getLevels();
		if (!levels.existing && !levels.existingFiles && !levels.missing) { // If all protection settings are disabled
			alert('既存ページ・未作成ページともに保護設定が無効化されています。');
			return;
		}

		tgtTab2.radio.checked = true;
		toggleDisableAttributes(true);
		massProtectMessage.appendChild(document.createTextNode('準備中'));
		massProtectMessage.appendChild(getIcon('doing'));

		// Get target pages
		createList(true).then(function(listObj) {

			massProtectMessage.innerHTML = '';

			// No pages specified
			if (!listObj) {
				toggleDisableAttributes(false);
				tgtTab1.radio.checked = true;
				removeForceHidden();
				alert('保護対象ページが入力されていません。');
				return;
			// Existing pages only and existing page protection disabled
			} else if (listObj.existingTitles.length && !listObj.missingTitles.length && !levels.existing) {
				toggleDisableAttributes(false);
				tgtTab1.radio.checked = true;
				removeForceHidden();
				alert('保護対象ページが既存ページのみの中、既存ページの保護が無効化されています。');
				return;
			// Missing pages only and missing page protection disabled
			} else if (!listObj.existingTitles.length && listObj.missingTitles.length && !levels.missing) {
				toggleDisableAttributes(false);
				tgtTab1.radio.checked = true;
				removeForceHidden();
				alert('保護対象ページが未作成ページのみの中、未作成ページの保護が無効化されています。');
				return;
			}

			// Get reason
			var reason = getReasons();
			if (!reason) {
				if (!confirm('保護理由が入力されていません。このまま実行しますか?')) {
					toggleDisableAttributes(false);
					removeForceHidden();
					return;
				}
			}

			// Create final confirm message
			/** @type {string[]} */
			var confirmMsg = [];
			var followingMsg = '';
			if (listObj.existingTitles.length) {
				if (levels.existing) {
					followingMsg = ' (' + (levels.existing.unprotect ? '<b>保護解除</b>' : '保護') + ')';
				} else {
					followingMsg = ' (既存ページの保護が無効のためスキップ)';
				}
				confirmMsg.push('既存ページ数: ' + listObj.existingTitles.length + followingMsg);
			}
			if (listObj.existingFiles.length) {
				if (levels.existingFiles) {
					followingMsg = ' (' + (levels.existingFiles.unprotect ? '<b>保護解除</b>' : '保護') + ')';
				} else {
					followingMsg = ' (既存ページの保護が無効のためスキップ)';
				}
				confirmMsg.push('既存ファイル数: ' + listObj.existingFiles.length + followingMsg);
			}
			if (listObj.missingTitles.length) {
				if (levels.missing) {
					followingMsg = ' (' + (levels.missing.unprotect ? '<b>作成保護解除</b>' : '作成保護') + ')';
				} else {
					followingMsg = ' (未作成ページの保護が無効のためスキップ)';
				}
				confirmMsg.push('未作成ページ数: ' + listObj.missingTitles.length + followingMsg);
			}

			mw.notify('入力内容をよく確認のうえ「続行」ボタンを押してください');
			linkConfirm(confirmMsg.join('<br>')).then(function(confirmed) {

				if (!confirmed) {
					toggleDisableAttributes(false);
					removeForceHidden();
					mw.notify('中止しました');
					return;
				}

				// Create an amalgamated object containing parameters for API requests and HTML elements to show progress, per page

				/** @type {ActionProtectDefaultParams} */
				var defaultParams = {
					action: 'protect',
					reason: reason,
					tags: mw.config.get('wgDBname') === 'jawiki' ? MP : undefined,
					cascade: settingCascade.checked,
					watchlist: settingWatch.checked ? 'watch' : 'nochange',
					watchlistexpiry: settingWatch.checked ? settingWatchExpiry.value: undefined,
					curtimestamp: true,
					formatversion: '2'
				};
				var ppOptions = getPpOptions();

				/** @type {ActionProtectParamsPerTitle} */
				var extendedParams = Object.keys(listObj.progress).reduce(/** @param {ActionProtectParamsPerTitle} acc */ function(acc, pagetitle) {

					// Create parameters for this title only if it can be forwarded to the API
					/** @type {ExtendedParams} */
					var params;
					var unprotect = false;
					if (listObj.existingTitles.indexOf(pagetitle) !== -1) { // This page exists
						if (levels.existing) { // and existing page protection is enabled
							params = {
								title: pagetitle,
								protections: levels.existing.protections.join('|'),
								expiry: listObj.progress[pagetitle].expiry.value || levels.existing.expiry.join('|')
							};
							unprotect = levels.existing.unprotect;
						}
					} else if (listObj.existingFiles.indexOf(pagetitle) !== -1) { // This file exists
						if (levels.existingFiles) { // and existing file protection is enabled
							params = {
								title: pagetitle,
								protections: levels.existingFiles.protections.join('|'),
								expiry: listObj.progress[pagetitle].expiry.value || levels.existingFiles.expiry.join('|')
							};
							unprotect = levels.existingFiles.unprotect;
						}
					} else if (listObj.missingTitles.indexOf(pagetitle) !== -1) { // This page is missing
						if (levels.missing) { // and missing page protection is enabled
							params = {
								title: pagetitle,
								protections: levels.missing.protections.join('|'),
								expiry: listObj.progress[pagetitle].expiry.value || levels.missing.expiry.join('|')
							};
							unprotect = levels.missing.unprotect;
						}
					} else { // Titles that reach here will be skipped in the MassProtect procedure
						return acc;
					}

					// Progress of page protection
					var progressObj = listObj.progress[pagetitle];
					progressObj.progress.label.appendChild(document.createTextNode('保護' + (unprotect ? '解除' : '') + ': '));
					progressObj.progress.msg.appendChild(getIcon('doing'));
					acc[pagetitle] = {
						list: progressObj.list,
						progress: progressObj.progress.msg,
						unprotect: unprotect,
						overwrite: settingConfirmToOverwrite.checked && settingConfirmToUnprotect.checked ?
									'confirmall' :
									settingConfirmToOverwrite.checked ?
									'confirmprotect' :
									'proceed',
					};

					// Progress of template pasting. This object property (progress2) is undefined if the option is disabled
					if (unprotect) {
						// 'Remove pp on unprotect' is enabled and the page exists
						if (ppOptions.removePp && listObj.missingTitles.indexOf(pagetitle) === -1) {
							progressObj.progress2.label.appendChild(document.createTextNode('タグ除去: '));
							progressObj.progress2.msg.appendChild(getIcon('doing'));
							acc[pagetitle].progress2 = progressObj.progress2.msg;
						}
					} else {
						// 'Add pp' or 'remove RFP' is enabled, the page exists, and the page isn't a module
						if ((ppOptions.addPp || ppOptions.removeRfp) &&
							listObj.missingTitles.indexOf(pagetitle) === -1 &&
							getNamespaceNumber(pagetitle) !== 828
						) {
							progressObj.progress2.label.appendChild(document.createTextNode('タグ: '));
							progressObj.progress2.msg.appendChild(getIcon('doing'));
							acc[pagetitle].progress2 = progressObj.progress2.msg;
						}
					}

					// @ts-ignore
					if (params) { // If this page is to be forwarded to the API for (un)protect

						// Get conjoined params
						var conjParams = $.extend(params, defaultParams);

						// Clean up the 'watch' and 'watchexpiry' parameters. The former should be set to 'nochange' and the latter should
						// be deleted if 'add to watchlist' is checked && this page is to be protected && 'relative expiry' is checked &&
						// the watchlist expiry isn't indefinite (in this case a 'relative' property is added to the extended param obj),
						// OR this page is to be unprocted && 'ignore pages to unprotect' is checked.
						if (settingWatch.checked) {
							if (!unprotect) { // Protect
								if (settingWatchRelativeExpiry.checked) { // Watch page by relative expiry
									if (settingWatchExpiry.value !== 'indefinite') { // Selected expiry isn't indefinite (can be relativized)
										// @ts-ignore
										acc[pagetitle].relative = settingWatchExpiry.value;
										conjParams.watchlist = 'nochange';
										delete conjParams.watchlistexpiry;
									} else { // Selected expiry is indefinite (can't be relativized)
										// Do nothing, use the values of defaultParams (watch, indefinite)
									}
								} else { // Watch page by absolute expiry
									// Do nothing, use the values of defaultParams (watch, selected expiry)
								}
							} else { // Unprotect
								if (!settingWatchExcludeUnprotect.checked) { // Watch page on unprotection
									// Do nothing, use the values of defaultParams (watch, selected expiry)
								} else { // Don't watch page on unprotection
									conjParams.watchlist = 'nochange';
									delete conjParams.watchlistexpiry;
								}
							}
						}

						// Register the params
						acc[pagetitle].params = conjParams;

					}

					return acc;
				}, Object.create(null));

				// Scroll up to the list wrapper, hiding all irrelevant fields.
				tgtWrapper.scrollIntoView({behavior: 'smooth'});
				[saveButtonWrapper, fetcherWrapper, settingWrapper, massProtectWrapper].forEach(function(el) {
					el.hidden = true;
				});

				// Execute
				console.log(MP, extendedParams);
				massProtect(extendedParams, ppOptions);

			});

		});
	});

}

/** Create a user right error interface. */
function showUserRightError() {

	var container = document.createElement('div');
	container.id = 'mp-container';
	replaceContent(container, '権限エラー');

	container.innerHTML =
		'<p>あなたには「ページの保護設定の変更」を行う権限がありません。理由は以下の通りです:</p>' +
		'<ul>' +
			'<li>' +
				'この操作は、以下のグループに属する利用者のみが実行できます: <a href="' + mw.util.getUrl('Wikipedia:管理者') + '">管理者</a>。' +
			'</li>' +
		'</ul>';

}

/**
 * Replace the DOM body with a new content.
 * @param {HTMLDivElement} newContent
 * @param {string} headerText
 */
function replaceContent(newContent, headerText) {
	var bodyContent = document.querySelector('.mw-body-content');
	if (bodyContent) {
		bodyContent.replaceChildren(newContent);
		var firstHeading = document.querySelector('.mw-first-heading');
		if (firstHeading) {
			firstHeading.textContent = headerText;
		}
	}
}

/**
 * Get a loading/check/cross icon image tag.
 * @param {"doing"|"done"|"failed"|"cancelled"} iconType
 * @returns {HTMLImageElement}
 */
function getIcon(iconType) {
	var img = document.createElement('img');
	switch (iconType) {
		case 'doing':
			img.src = '//upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif';
			break;
		case 'done':
			img.src = '//upload.wikimedia.org/wikipedia/commons/f/fb/Yes_check.svg';
			break;
		case 'failed':
			img.src = '//upload.wikimedia.org/wikipedia/commons/a/a2/X_mark.svg';
			break;
		case 'cancelled':
			img.src = '//upload.wikimedia.org/wikipedia/commons/6/61/Symbol_abstain_vote.svg';
	}
	img.style.cssText = 'vertical-align: middle; height: 1em; border: 0;';
	return img;
}

/**
 * @typedef ProtectionStatus
 * @type {object}
 * @property {{
 *	user: string;
 *	timestamp: string;
 *	reason: string;
 *	level: string[];
 * }} [current] This property is missing if the page is currently not protected.
 * @property {{
 *	count: number;
 *	latest: string[];
 * }} previous
 */

/**
 * Get the protection logs of given pages.
 * @param {string[]} pagetitlesArr
 * @returns {JQueryPromise<Object.<string, ProtectionStatus|undefined>>}
 */
function getProtectionLogs(pagetitlesArr) {

	/**
	 * @param {string} pagetitle
	 * @returns {JQueryPromise<ProtectionStatus|undefined>}
	 */
	var query = function(pagetitle) {

		return api.get({
			action: 'query',
			titles: pagetitle,
			prop: 'info',
			inprop: 'protection',
			list: 'logevents',
			letype: 'protect',
			letitle: pagetitle,
			lelimit: 'max',
			formatversion: '2'
		}).then(function(res) {

			var resPages, resLgev;
			if (!res || !res.query || !(resPages = res.query.pages) || !(resLgev = res.query.logevents)) return undefined;

			/** @type {ProtectionStatus} */
			var ret = {
				previous: {
					count: 0,
					latest: []
				}
			};
			var currentProtection = detailsObjectArrayToStringArray(resPages[0].protection);
			/** @type {Date|undefined} */
			var lastProtectionDate;
			/** @type {object} */
			var newestProtectionObject;

			resLgev.forEach(function(obj) {

				// Only look at new protections and modifications
				if (['protect', 'modify'].indexOf(obj.action) === -1) {
					return;
				} else if (!newestProtectionObject) {
					newestProtectionObject = obj;
				}

				// If currently protected, save that information only once
				if (!ret.current && currentProtection.length) {
					ret.current = {
						user: obj.user,
						timestamp: obj.timestamp,
						reason: obj.comment,
						level: currentProtection
					};
				}

				// Save the latest protection log (note that 'params' can be an empty object in old logs)
				var details;
				if (!ret.previous.latest.length && obj.params && Array.isArray((details = obj.params.details))) {
					ret.previous.latest = detailsObjectArrayToStringArray(details, obj.timestamp);
					ret.previous.latest.push(parseIsoTimestamp(obj.timestamp));
				}

				// Increment protection counter if this log entry is one that was generated at least 1 day earlier than the newer log entry
				if (!lastProtectionDate || lastProtectionDate.getTime() - (lastProtectionDate = new Date(obj.timestamp)).getTime() > 1*24*60*60*1000) {
					ret.previous.count++;
					if (obj.action === 'protect') lastProtectionDate = undefined;
				}

			});

			// If the page has protection logs but the latest log information can't be fetched, push a parsed timestamp
			if (ret.previous.count && !ret.previous.latest.length && newestProtectionObject) {
				ret.previous.latest.push(parseIsoTimestamp(newestProtectionObject.timestamp));
			}

			return ret;

		}).catch(function(code, err) {
			console.log(MP, err);
			return undefined;
		});
	};

	var deferreds = [];
	pagetitlesArr.forEach(function(pagetitle) {
		deferreds.push(query(pagetitle));
	});
	return $.when.apply($, deferreds).then(function() {
		/** @type {Object.<string, ProtectionStatus|undefined>} */
		var ret = {};
		var args = arguments;
		for (var i = 0; i < args.length; i++) {
			ret[pagetitlesArr[i]] = args[i];
		}
		return ret;
	});

}

/**
 * res.query.pages[index].protection or res.query.logevents[index].params.details
 * @typedef ApiResponseProtectionDetails
 * @type {{
 *	type: string;
 *	level: string;
 *	expiry: string;
 *	cascase?: boolean;
 * }}
 */

/**
 * Convert an array of ApiResponseProtectionDetails objects to an array of human-readable strings (e.g. "Edit semi-protected (indefinite)").
 * If the second argument isn't passed, conversion takes place only if the objects signify that the relevant page is currently protected.
 * @param {ApiResponseProtectionDetails[]} detailsArr
 * @param {string} [baseTimestamp] If provided, get duration instead of expiry timestamp
 * @returns {string[]}
 */
function detailsObjectArrayToStringArray(detailsArr, baseTimestamp) {
	return detailsArr.reduce(/** @param {string[]} acc */ function(acc, obj) {
		if (!baseTimestamp && !isProtected(obj)) return acc;
		var duration = /^in/.test(obj.expiry) ? '無期限' : baseTimestamp ? getDuration(baseTimestamp, obj.expiry) : obj.expiry.replace(/Z$/, '') + 'まで';
		var el = translate(obj.type) + translate(obj.level) + ' (' + duration + ')';
		acc.push(el);
		return acc;
	}, []);
}

/**
 * Look at the details array of a list=logevents&letype=protect response and check if the relevant page is currently protected.
 * @param {ApiResponseProtectionDetails} detailsObj
 * @returns {boolean}
 */
function isProtected(detailsObj) {
	var d = new Date();
	if (!detailsObj.expiry) {
		return false;
	} else if (/^in/.test(detailsObj.expiry)) {
		return true;
	} else {
		return d < new Date(detailsObj.expiry);
	}
}

/**
 * Get the difference between two timestamps in a human-readable format, by subtracting timestamp2 by timestamp1.
 * If the result is a negative value, undefined is returned.
 * @param {string} timestamp1
 * @param {string} timestamp2
 */
function getDuration(timestamp1, timestamp2) {

	var ts1 = new Date(timestamp1);
	var ts2 = new Date(timestamp2);
	var diff = ts2.getTime() - ts1.getTime();
	if (diff < 0) return;

	var seconds = Math.round(diff / 1000);
	var minutes = Math.round(seconds / 60);
	var hours = Math.round(minutes / 60);
	var days = Math.round(hours / 24);
	var weeks = Math.round(days / 7);
	var months = Math.round(days / 30);
	var years = Math.floor(days / 365);
	seconds %= 60;
	minutes %= 60;
	hours %= 24;
	days %= 30;
	weeks %= 7;
	months %= 30;
	years %= 365;

	var duration, unit;
	if (years) {
		duration = years;
		unit = '年';
	}
	else if (months) {
		duration = months;
		unit = 'か月';
	}
	else if (weeks) {
		duration = weeks;
		unit = '週間';
	}
	else if (days) {
		duration = days;
		unit = '日';
	}
	else if (hours) {
		duration = hours;
		unit = '時間';
	}
	else if (minutes) {
		duration = minutes;
		unit = '分';
	}
	else {
		duration = seconds;
		unit = '秒';
	}
	switch (unit) {
		case 'か月':
			if (duration % 12 === 0) {
				duration /= 12;
				unit = '年';
			}
			break;
		case '週間':
			if (duration % 4 === 0) {
				duration /= 4;
				unit = 'か月';
			}
			break;
		case '日':
			if (duration % 7 === 0) {
				duration /= 7;
				unit = '週間';
			}
			break;
		case '時間':
			if (duration % 24 === 0) {
				duration /= 24;
				unit = '日';
			}
			break;
		case '分':
			if (duration % 60 === 0) {
				duration /= 60;
				unit = '時間';
			}
			break;
		case '秒':
			if (duration % 60 === 0) {
				duration /= 60;
				unit = '分';
			}
			break;
		default:
	}
	return duration + unit;

}

var translations = {
	create: '作成',
	edit: '編集',
	move: '移動',
	upload: 'アップロード',
	autoconfirmed: '半保護',
	extendedconfirmed: '拡張半保護',
	sysop: '全保護'
};

/**
 * @param {string} keyword
 * @returns {string}
 */
function translate(keyword) {
	return translations[keyword] || '??';
}

/**
 * Parse an ISO timestamp (YYYY-MM-DDThh:mm:ssZ) and get 'YYYY年MM月DD日'.
 * @param {string} ts
 * @returns {string}
 */
function parseIsoTimestamp(ts) {
	var m = ts.match(/^(\d{4})-0?(\d{1,2})-0?(\d{1,2})/);
	// @ts-ignore
	return m[1] + '年' + m[2] + '月' + m[3] + '日';
}

/**
 * @typedef PageInfo
 * @property {string} title Spaces are represented by underscores.
 * @property {boolean} missing
 */
/**
 * Check whether given pages exist.
 * @param {string[]} titlesArr
 * @returns {JQueryPromise<PageInfo[]>} Spaces in pagetitles are represented by underscores.
 */
function checkPageExistence(titlesArr) {

	/** @type {PageInfo[]} */
	var pageInfo = [];

	/**
	 * @param {string[]} titles
	 * @returns {JQueryPromise<void>}
	 */
	var query = function(titles) {
		return api.post({
			action: 'query',
			titles: titles.join('|'),
			formatversion: '2'
		}).then(function(res) {

			var resPages;
			if (!res || !res.query || !(resPages = res.query.pages) || !resPages.length) return;

			resPages.forEach(function(obj) {
				if (!obj.title) return;
				obj.title = obj.title.replace(/ /g, '_');
				pageInfo.push({
					title: obj.title,
					missing: !!obj.missing
				});
			});

		}).catch(function(code, err) {
			console.error(MP, err);
			return;
		});
	};

	titlesArr = titlesArr.slice();
	var deferreds = [];
	while (titlesArr.length) {
		deferreds.push(query(titlesArr.splice(0, apilimit)));
	}
	return $.when.apply($, deferreds).then(function() {
		return pageInfo;
	});

}

/**
 * Get the namespace number of a pagetitle.
 * @param {string} pagetitle
 * @returns {number}
 */
function getNamespaceNumber(pagetitle) {
	var prefix = pagetitle.replace(/ /g, '_').split(':')[0].toLowerCase();
	var namespaceIds = mw.config.get('wgNamespaceIds');
	for (var alias in namespaceIds) {
		if (prefix === alias) return namespaceIds[alias];
	}
	return 0;
}

/**
 * @param {HTMLElement} appendTo
 * @param {string} id
 * @param {string} label
 * @param {{buttonCss?: string;}} [options]
 * @returns {HTMLInputElement}
 */
function createButton(appendTo, id, label, options) {
	options = options || {};
	var button = document.createElement('input');
	button.type = 'button';
	if (id) button.id = id;
	button.value = label;
	if (options.buttonCss) button.style.cssText = options.buttonCss;
	appendTo.appendChild(button);
	return button;
}

/**
 * Create a dropdown with a label on its left. Default CSS for the dropdown: 'display: inline-block;'
 * @param {HTMLElement} appendTo
 * @param {string} id
 * @param {string} labelText
 * @param {{labelCss?: string; dropdownCss?: string; appendBr?: boolean;}} [options]
 * @returns {HTMLSelectElement}
 */
function createLabeledDropdown(appendTo, id, labelText, options) {

	options = options || {};
	if (options.appendBr) appendTo.appendChild(document.createElement('br'));

	var label = document.createElement('label');
	label.htmlFor = id;
	label.textContent = labelText;
	label.style.cssText = 'display: inline-block;';
	if (options.labelCss) parseAndApplyCssText(label, options.labelCss);
	appendTo.appendChild(label);

	var dropdown = document.createElement('select');
	dropdown.id = id;
	if (options.dropdownCss) dropdown.style.cssText = options.dropdownCss;
	appendTo.appendChild(dropdown);

	return dropdown;

}

/**
 * Create a textbox with a label on its left. Default CSS for the textbox: 'display: inline-block;'
 * @param {HTMLElement} appendTo
 * @param {string} id
 * @param {string} labelText
 * @param {{labelCss?: string; textboxCss?: string; appendBr?: boolean;}} [options]
 * @returns {HTMLInputElement}
 */
function createLabeledTextbox(appendTo, id, labelText, options) {

	options = options || {};
	if (options.appendBr) appendTo.appendChild(document.createElement('br'));

	var label = document.createElement('label');
	label.htmlFor = id;
	label.textContent = labelText;
	label.style.cssText = 'display: inline-block;';
	if (options.labelCss) parseAndApplyCssText(label, options.labelCss);
	appendTo.appendChild(label);

	var textbox = document.createElement('input');
	textbox.type = 'text';
	textbox.id = id;
	if (options.textboxCss) textbox.style.cssText = options.textboxCss;
	appendTo.appendChild(textbox);

	return textbox;

}

/**
 * Create a checkbox with a label on its right. Default CSS for the checkbox: 'margin-right: 0.5em;'
 * @param {HTMLElement} appendTo
 * @param {string} id
 * @param {string} labelText This is applied to innerHTML, not textContent.
 * @param {{checkboxCss?: string; appendBr?: boolean; wrapDiv?: boolean;}} [options]
 * @returns {HTMLInputElement}
 */
function createLabeledCheckbox(appendTo, id, labelText, options) {

	options = options || {};
	if (options.appendBr) appendTo.appendChild(document.createElement('br'));

	var checkbox = document.createElement('input');
	checkbox.type = 'checkbox';
	checkbox.id = id;
	checkbox.style.marginRight = '0.5em';
	if (options.checkboxCss) parseAndApplyCssText(checkbox, options.checkboxCss);

	var label = document.createElement('label');
	label.htmlFor = id;
	label.innerHTML = labelText;

	if (options.wrapDiv) {
		var wrapper = document.createElement('div');
		wrapper.appendChild(checkbox);
		wrapper.appendChild(label);
		appendTo.appendChild(wrapper);
	} else {
		appendTo.appendChild(checkbox);
		appendTo.appendChild(label);
	}

	return checkbox;

}

/**
 * Create a "?" mark tooltip.
 * @param {HTMLElement} appendTo
 * @param {string} tooltipText
 */
function createTooltip(appendTo, tooltipText) {
	var tt = document.createElement('span');
	tt.style.cssText = 'font-weight: bold; color: blue;';
	tt.textContent = '?';
	tt.title = tooltipText;
	$(tt).tooltip({
		tooltipClass: 'mp-tooltip',
		position: {
			my: 'left bottom',
			at: 'left top'
		}
	});
	appendTo.appendChild(tt);
};

/**
 * Parse cssText ('property: value;') recursively and apply the styles to a given element.
 * @param {HTMLElement} element
 * @param {string} cssText
 */
function parseAndApplyCssText(element, cssText) {
	var regex = /(\S+?)\s*:\s*(\S+?)\s*;/g;
	var m;
	while ((m = regex.exec(cssText))) {
		element.style[m[1]] = m[2];
	}
}

/**
 * Get the content of a page, parse templates in it, and filter out the values of the first parameter.
 * @param {{title: string; templateName: RegExp; sectionName?: string;}} parseConfig
 * @returns {JQueryPromise<string[]|string>} Returns an array of strings on success, otherwise an error message as a string.
 */
function getFirstTemplateParameters(parseConfig) {
	return read(parseConfig.title).then(function(content) {

		if (content === null) {
			return 'ページが見つかりませんでした';
		} else if (content === undefined) {
			return '取得に失敗しました';
		}
		if (parseConfig.sectionName) {
			var sections = parseSections(content).filter(function(obj) {
				return obj.title === parseConfig.sectionName;
			});
			if (!sections.length) return '指定された名前のセクションが見つかりませんでした';
			content = sections[0].content;
		}

		var templates = parseTemplates(content, {namePredicate: function(name) {
			return parseConfig.templateName.test(name);
		}});

		return templates.reduce(/** @param {string[]} acc */ function(acc, Template) {
			/** @type {TemplateArgument[]} */
			var filtered;
			if ((filtered = Template.arguments.filter(function(obj) { return obj.name === '1'; })).length) {
				if (filtered[0].value && acc.indexOf(filtered[0].value) === -1) acc.push(filtered[0].value);
			}
			return acc;
		}, []);

	});
}

/**
 * Parse the content of a page by each section.
 * @param {string} content
 * @returns {{header: string|null; title: string|null; level: number; index: number; content: string; deepest: boolean|null;}[]}
 */
function parseSections(content) {

	var regex = {
		comments: /<!--[\s\S]*?-->|<(nowiki|pre|syntaxhighlight|source|math)[\s\S]*?<\/\1\s*>/gi,
		header: /={2,5}[^\S\n\r]*.+[^\S\n\r]*={2,5}/,
		headerG: /={2,5}[^\S\n\r]*.+[^\S\n\r]*={2,5}/g,
		headerEquals: /(?:^={2,5}[^\S\n\r]*|[^\S\n\r]*={2,5}$)/g
	};

	// Replace comment-related tags
	var idx = 0;
	var comments = [];
	var m;
	while ((m = regex.comments.exec(content))) {
		content = content.replace(m[0], '$CM' + (idx++));
		comments.push(m[0]);
	}

	// Get headers
	/** @type {{text:string; title:string; level: number; index: number;}[]} */
	var headers = [];
	while ((m = regex.headerG.exec(content))) {
		headers.push({
			text: m[0],
			title: m[0].replace(regex.headerEquals, ''),
			level: (m[0].match(/=/g) || []).length / 2,
			index: m.index // This is the index number of the header in the content
		});
	}
	headers.unshift({text: '', title: '', level: 1, index: 0}); // For the top section

	// Return an array of objects
	return headers.map(function(obj, i, arr) {
		var isTopSection = i === 0;
		var sectionContent = arr.length > 1 ? content.slice(0, arr[1].index) : content;
		var deepest = null;
		if (!isTopSection) {
			var nextSameLevelSection = arr.slice(i + 1).filter(function(objF) {return objF.level <= obj.level; });
			sectionContent = content.slice(obj.index, nextSameLevelSection.length ? nextSameLevelSection[0].index : content.length);
			var dRegex = new RegExp('(={' + (obj.level + 1) + ',})[^\\S\\n\\r]*.+[^\\S\\n\\r]*\\1');
			deepest = !dRegex.test(sectionContent.slice(obj.text.length));
		}
		comments.forEach(function(el, j){ sectionContent = sectionContent.replace('$CM' + j, el); }); // Get comments back
		return {
			header: isTopSection ? null : obj.text,
			title: isTopSection ? null : obj.title,
			level: obj.level,
			index: i,
			content: sectionContent,
			deepest: deepest
		};
	});

}

/**
 * @typedef TemplateConfig
 * @property {boolean} [recursive] Whether to parse templates nested inside others. Defaulted to true.
 * @property {boolean} [parseComments] Whether to parse templates inside comment-related tags. Defaulted to false.
 * @property {NamePredicate} [namePredicate] Include template in result only if its name matches this predicate.
 * @property {TemplatePredicate} [templatePredicate] Include template in result only if it matches this predicate.
 * @callback NamePredicate
 * @param {string} name Template name (first letter in uppercase, spaces represented by underscores)
 * @returns {boolean}
 * @callback TemplatePredicate
 * @param {Template} Template Template object
 * @returns {boolean}
 * @typedef Template
 * @property {string} text The whole text of the template, starting with '{{' and ending with '}}'.
 * @property {string} name Name of the template. The first letter is always in upper case and spaces are represented by underscores.
 * @property {TemplateArgument[]} arguments Parsed template arguments as an array.
 * @property {number} nestlevel Nest level of the template. If it's not part of another template, the value is 0.
 * @typedef TemplateArgument
 * @property {string} text The whole text of the template argument.
 * @property {string} name A key name such as '1' (numeral key is assigned if not explicitly defined)
 * @property {string} value A value following a key such as '1='.
 */

/**
 * Parse templates in wikitext. Templates within \<!-- -->, nowiki, pre, syntaxhighlight, source, and math are ignored.
 * (Those in comment tags can be parsed if TemplateConfig.parseComments is true.)
 * @param {string} wikitext Text in which to parse templates.
 * @param {TemplateConfig} [config]
 * @param {number} [nestlevel] Private parameter. Do not specify this manually.
 * @returns {Template[]}
 */
function parseTemplates(wikitext, config, nestlevel) {

	/** @type {TemplateConfig} */
	var cfg = {
		recursive: true,
		parseComments: false,
		namePredicate: undefined,
		templatePredicate: undefined
	};
	$.extend(cfg, config || {});

	nestlevel = typeof nestlevel === 'number' ? nestlevel : 0;

	// Number of unclosed braces
	var numUnclosed = 0;

	// Are we in a {{{parameter}}}, or between wikitags that prevent transclusions?
	var inParameter = false;
	var inTag = false;
	var tagNames = [];

	var parsed = [];
	var startIdx, endIdx;

	// Look at every character of the wikitext one by one. This loop only extracts the outermost templates.
	for (var i = 0; i < wikitext.length; i++) {
		var slicedWkt = wikitext.slice(i);
		var matchedTag, isComment;
		if (!inParameter && !inTag) {
			if (/^\{\{\{(?!\{)/.test(slicedWkt)) {
				inParameter = true;
				i += 2;
			} else if (/^\{\{/.test(slicedWkt)) {
				if (numUnclosed === 0) {
					startIdx = i;
				}
				numUnclosed += 2;
				i++;
			} else if (/^\}\}/.test(slicedWkt)) {
				if (numUnclosed === 2) {
					endIdx = i + 2;
					var templateText = wikitext.slice(startIdx, endIdx); // Pipes could have been replaced with a control character if they're part of nested templates
					var templateTextPipesBack = _replacePipesBack(templateText);
					var templateName = templateTextPipesBack.replace(/\u200e|^\{\{\s*(:?\s*template\s*:|:?\s*テンプレート\s*:)?\s*|\s*[|}][\s\S]*$/gi, '').replace(/ /g, '_');
					templateName = ucFirst(templateName);
					if (!cfg.namePredicate || cfg.namePredicate(templateName)) {
						parsed.push({
							text: templateTextPipesBack,
							name: templateName,
							arguments: _parseTemplateArguments(templateText),
							nestlevel: nestlevel
						});
					}
				}
				numUnclosed -= 2;
				i++;
			} else if (wikitext[i] === '|' && numUnclosed > 2) { // numUnclosed > 2 means we're in a nested template
				// Swap out pipes with \x01 character.
				wikitext = strReplaceAt(wikitext, i, '\x01');
			} else if ((matchedTag = slicedWkt.match(/^(?:<!--|<(nowiki|pre|syntaxhighlight|source|math) ?[^>]*?>)/))) {
				isComment = /^<!--/.test(slicedWkt);
				if (!(cfg.parseComments && isComment)) {
					inTag = true;
					tagNames.push(isComment ? 'comment' : matchedTag[1]);
					i += matchedTag[0].length - 1;
				}
			}
		} else {
			// we are in a {{{parameter}}} or tag
			if (wikitext[i] === '|' && numUnclosed > 2) {
				wikitext = strReplaceAt(wikitext, i, '\x01');
			} else if ((matchedTag = slicedWkt.match(/^(?:-->|<\/(nowiki|pre|syntaxhighlight|source|math) ?[^>]*?>)/))) {
				isComment = /^-->/.test(slicedWkt);
				if (!(cfg.parseComments && isComment)) {
					inTag = false;
					tagNames.pop();
					i += matchedTag[0].length - 1;
				}
			} else if (/^\}\}\}/.test(slicedWkt)) {
				inParameter = false;
				i += 2;
			}
		}
	}

	// Get nested templates?
	if (cfg.recursive) {
		/** @type {Template[]} */
		var accumulator = [];
		var subtemplates = parsed.reduce(function(acc, obj) {
			var tempInner = obj.text.slice(2, -2);
			if (/\{\{[\s\S]*?\}\}/.test(tempInner)) {
				// @ts-ignore
				acc = acc.concat(parseTemplates(tempInner, cfg, nestlevel + 1));
			}
			return acc;
		}, accumulator);
		parsed = parsed.concat(subtemplates);
	}
	// Filter the array by a user-defined condition?
	if (cfg.templatePredicate) {
		// @ts-ignore
		parsed = parsed.filter(function(Template) { return cfg.templatePredicate(Template); });
	}

	return parsed;

}

/**
 * This function should never be called externally because it presupposes that pipes in nested templates have been replaced with the control character '\x01',
 * and otherwise it doesn't work as expeceted.
 * @param {string} template
 * @returns {TemplateArgument[]}
 */
function _parseTemplateArguments(template) {

	if (template.indexOf('|') === -1) return [];

	var innerContent = template.slice(2, -2); // Remove braces

	// Swap out pipes in links with \x01 control character
	// [[File: ]] can have multiple pipes, so might need multiple passes
	var wikilinkRegex = /(\[\[[^\]]*?)\|(.*?\]\])/g;
	while (wikilinkRegex.test(innerContent)) {
		innerContent = innerContent.replace(wikilinkRegex, '$1\x01$2');
	}

	var args = innerContent.split('|');
	args.shift(); // Remove template name
	var unnamedArgCount = 0;

	/** @type {TemplateArgument[]} */
	var parsedArgs = args.map(function(arg) {

		arg = arg.trim();

		// Replace {{=}}s with a (unique) control character
		// The magic words could have spaces before/after the equal sign in an inconsistent way
		// We need the input string back as it was before replacement, so mandane replaceAll isn't a solution here
		var magicWordEquals = arg.match(/\{\{\s*=\s*\}\}/g) || [];
		magicWordEquals.forEach(function(equal, i) {arg = arg.replace(equal, '$EQ' + i); });

		var argName, argValue;
		var indexOfEqual = arg.indexOf('=');
		if (indexOfEqual >= 0) { // The argument is named
			argName = arg.slice(0, indexOfEqual).trim();
			argValue = arg.slice(indexOfEqual + 1).trim();
			if (argName === unnamedArgCount.toString()) unnamedArgCount++;
		} else { // The argument is unnamed
			argName = (++unnamedArgCount).toString();
			argValue = arg.trim();
		}

		// Get the replaced {{=}}s back
		magicWordEquals.forEach(function(equal, i) {
			var replacee = '$EQ' + i;
			arg = arg.replace(replacee, equal);
			argName = argName.replace(replacee, equal);
			argValue = argValue.replace(replacee, equal);
		});

		return {
			text: _replacePipesBack(arg),
			name: _replacePipesBack(argName),
			value: _replacePipesBack(argValue)
		};

	});

	return parsedArgs;

}

/**
 * Capitalize the first letter of a string.
 * @param {string} string
 * @returns {string}
 */
function ucFirst(string) {
	return string.charAt(0).toUpperCase() + string.slice(1);
}

/**
 * Replace the nth character in a string with a given string.
 * @param {string} string
 * @param {number} index
 * @param {string} char
 * @returns {string}
 */
function strReplaceAt(string, index, char) {
	return string.slice(0, index) + char + string.slice(index + 1);
}

/**
 * @param {string} string
 * @returns {string}
 */
function _replacePipesBack(string) {
	return string.replace(/\x01/g, '|');
}

/**
 * Get pagetitles that belong to a given category.
 * @param {string} cattitle A 'Category:' prefix is automatically added if there's none
 * @param {string[]} namespaces
 * @returns {JQueryPromise<string[]>} Spaces are represented by underscores.
 */
function getCatMembers(cattitle, namespaces) {

	if (!/^Category:/i.test(cattitle)) cattitle = 'Category:' + cattitle;

	/** @type {string[]} */
	var cats = [];

	var query = function(cmcontinue) {
		return api.get({
			action: 'query',
			list: 'categorymembers',
			cmtitle: cattitle,
			cmprop: 'title',
			cmnamespace: namespaces.join('|'),
			cmtype: 'page|file|subcat',
			cmlimit: 'max',
			cmcontinue: cmcontinue,
			formatversion: '2'
		}).then(function(res) {
			var resCM, resCont;
			if (!res || !res.query || !(resCM = res.query.categorymembers) || !resCM.length) return undefined;
			resCM.forEach(function(obj) {
				var title = obj.title;
				if (!title) return;
				title = title.replace(/ /g, '_');
				if (cats.indexOf(title) === -1) {
					cats.push(title);
				}
			});
			return res.continue && (resCont = res.continue.cmcontinue) ? query(resCont) : undefined;
		}).catch(function(code, err) {
			console.error(MP, err);
			return undefined;
		});
	};

	return query().then(function() {
		return cats;
	});

}

/**
 * Get pagetitles from a user's contributions.
 * @param {string} username
 * @param {string[]} namespaces A string array of namespace numbers. Pass ['*'] for all namespaces.
 * @param {string} [oldestTs] Filter out contribs AFTER this timestamp.
 * @param {string} [newestTs] Filter out contribs BEFORE this timestamp.
 * @returns {JQueryPromise<string[]|undefined>} Spaces are represented by underscores; Could be an empty array
 */
function getUserContribs(username, namespaces, oldestTs, newestTs) {

	var isCidr = mw.util.isIPAddress(username, true) && !mw.util.isIPAddress(username);
	var defaultParams = {
		action: 'query',
		list: 'usercontribs',
		uclimit: 'max',
		ucnamespace: namespaces.join('|'),
		ucprop: 'title|timestamp',
		formatversion: '2'
	};
	/** @param {string} ts */
	var inTimeRange = function(ts) {
		return true;
	};
	if (isCidr) {

		defaultParams.uciprange = username;

		// For some reason, queries for a CIDR's contribs are extremely slow and can even throw TimeoutException if ucstart/ucend
		// are specified. It's much faster to get all the contribs of the CIDR and filter them out.
		var dOldest = oldestTs ? new Date(oldestTs) : null;
		var dNewest = newestTs ? new Date(newestTs) : null;
		inTimeRange = function(ts) {
			var d = new Date(ts);
			if (dOldest && dNewest) {
				return dOldest <= d && d <= dNewest;
			} else if (dOldest) {
				return dOldest <= d;
			} else if (dNewest) {
				return d <= dNewest;
			} else {
				return true;
			}
		};

	} else {
		defaultParams.ucuser = username;
		$.extend(defaultParams, {
			ucstart: newestTs,
			ucend: oldestTs
		});
	}

	var failed = false;
	/** @type {TitleObject} */
	var titleObj = {};

	/**
	 * @param {string} [uccontinue]
	 * @returns {JQueryPromise<void>}
	 */
	var query = function(uccontinue) {
		var params = $.extend({uccontinue: uccontinue}, defaultParams);
		// @ts-ignore
		return api.get(params, {timeout: isCidr ? 0 : 30*1000})
			.then(function(res) {

				var resUc;
				if (!res || !res.query || !(resUc = res.query.usercontribs)) {
					failed = true;
					return;
				}

				resUc.forEach(function(obj) {
					var title = obj.title;
					if (!title) return;
					title = title.replace(/ /g, '_');
					if (inTimeRange(obj.timestamp) && (!titleObj[title] || isCidr && new Date(titleObj[title]) < new Date(obj.timestamp))) {
						titleObj[title] = obj.timestamp;
					}
				});

				var resCont;
				if (res.continue && (resCont = res.continue.uccontinue)) {
					return query(resCont);
				}

			})
			.catch(function(code, err) {
				console.error(MP, err);
				failed = true;
				return;
			});
	};

	return query().then(function() {
		return failed ? undefined : !isCidr ? Object.keys(titleObj) : sortTitleObjectArray(titleObj);
	});

}

/**
 * Get pagetitles from a user's deleted contributions. Incompatible with CIDRs.
 * @param {string} username
 * @param {string[]} namespaces A string array of namespace numbers. Pass ['*'] for all namespaces.
 * @param {string} [oldestTs] Filter out contribs AFTER this timestamp.
 * @param {string} [newestTs] Filter out contribs BEFORE this timestamp.
 * @returns {JQueryPromise<string[]|undefined>} Spaces are represented by underscores; Could be an empty array
 */
function getDeletedUserContribs(username, namespaces, oldestTs, newestTs) {

	var defaultParams = {
		action: 'query',
		list: 'alldeletedrevisions',
		adrlimit: 'max',
		adruser: username,
		adrnamespace: namespaces.join('|'),
		adrprop: 'timestamp',
		adrstart: newestTs,
		adrend: oldestTs,
		formatversion: '2'
	};

	var failed = false;
	/** @type {TitleObject} */
	var titleObj = {};

	/**
	 * @param {string} [adrcontinue]
	 * @returns {JQueryPromise<void>}
	 */
	var query = function(adrcontinue) {
		var params = $.extend({adrcontinue: adrcontinue}, defaultParams);
		// @ts-ignore
		return api.get(params)
			.then(function(res) {

				var resAdr;
				if (!res || !res.query || !(resAdr = res.query.alldeletedrevisions)) {
					failed = true;
					return;
				}

				resAdr.forEach(function(obj) {
					var title = obj.title;
					if (!title) return;
					title = title.replace(/ /g, '_');
					if (obj.revisions && obj.revisions.length && obj.revisions[0].timestamp && !titleObj[title]) {
						titleObj[title] = obj.revisions[0].timestamp;
					}
				});

				var resCont;
				if (res.continue && (resCont = res.continue.adrcontinue)) {
					return query(resCont);
				}

			})
			.catch(function(code, err) {
				console.error(MP, err);
				failed = true;
				return;
			});
	};

	return query().then(function() {
		return failed ? undefined : sortTitleObjectArray(titleObj);
	});

}

/**
 * Sort an object of pagetitle-timestamp pairs by timestamp and return an array of sorted pagetitles. Duplicates are not checked.
 * @param {TitleObject} titleObj
 * @returns {string[]}
 * @typedef {Object.<string, string>} TitleObject pagetitle-timestamp pairs
 */
function sortTitleObjectArray(titleObj) {
	return Object.keys(titleObj).sort(function(title1, title2) { // Sort the array, newer first
		var d1 = new Date(titleObj[title1]);
		var d2 = new Date(titleObj[title2]);
		if (d1 > d2) {
			return -1;
		} else if (d1 < d2) {
			return 1;
		} else {
			return 0;
		}
	});
}

/**
 * Create a link selector dialog from a raw html.
 * @param {string} html
 * @returns {JQueryPromise<string[]>}
 */
function linkSelector(html) {

	var def = $.Deferred();

	// Create the content of the dialog
	var dialog = document.createElement('div');
	dialog.id = 'mp-targets-fetcher-linkselector-dialog';
	dialog.title = 'MassDelete - LinkSelector';
	dialog.style.cssText = 'max-height: 80vh; max-width: 90vw;';

	var dialogHeader = document.createElement('div');
	dialogHeader.style.padding = '0.5em';
	dialogHeader.innerHTML =
		'リンクを選択してください(通常クリック: 選択、SHIFTクリック: 選択解除;リンク先は開きません)<br>' +
		'<b>黄緑色のリンク</b>は削除/復帰対象にできないページへのリンクです(クリックするとリンク先が開きます)';
	dialog.appendChild(dialogHeader);

	var dialogBody = document.createElement('div');
	dialogBody.style.cssText = 'border: 1px solid silver; padding: 0.5em; background: white; max-height: 60vh; overflow-y: scroll;';
	dialogBody.innerHTML = html;
	dialog.appendChild(dialogBody);

	var dialogFooter = document.createElement('div');
	dialogFooter.style.padding = '0.5em';
	dialogFooter.appendChild(document.createTextNode('選択済みページ数: '));
	var linkCnt = document.createElement('span');
	linkCnt.id = 'mp-targets-fetcher-linkselector-dialog-linkcount';
	linkCnt.textContent = '0';
	dialog.appendChild(dialogFooter);
	dialogFooter.appendChild(linkCnt);

	var endpoint = '^https?:' + mw.config.get('wgServer');
	var regex = {
		article: new RegExp(endpoint + mw.config.get('wgArticlePath').replace('$1', '([^#?]+)')), // '/wiki/PAGENAME'
		script: new RegExp(endpoint + mw.config.get('wgScript') + '\\?title=([^#&]+)') // '/w/index.php?title=PAGENAME'
	};

	// Loop all anchors
	/** @type {Object.<string, HTMLAnchorElement[]>} */
	var links = {};
	/** @type {string[]} */
	var pages = [];
	var invalidLinkClass = 'mp-targets-fetcher-linkselector-dialog-invalidlink';
	var selectedLinkClass = 'mp-targets-fetcher-linkselector-dialog-selectedlink';
	Array.prototype.forEach.call(dialogBody.getElementsByTagName('a'), /** @param {HTMLAnchorElement} a */ function(a) {

		var href = a.href;
		if (!href || a.role === 'button') {
			a.classList.add(invalidLinkClass);
			return;
		}
		a.target = '_blank';

		var m, pagetitle;
		if ((m = regex.article.exec(href))) {
			pagetitle = m[1];
		} else if ((m = regex.script.exec(href))) {
			pagetitle = m[1];
		} else {
			a.classList.add(invalidLinkClass);
			return;
		}
		pagetitle = decodeURIComponent(pagetitle);

		try {
			var Title = new mw.Title(pagetitle);
			if (Title.getNamespaceId() === -1) {
				a.classList.add(invalidLinkClass);
				return;
			}
			pagetitle = Title.getPrefixedDb();
		}
		catch (err) {
			a.classList.add(invalidLinkClass);
			return;
		}
		if (links[pagetitle]) {
			links[pagetitle].push(a);
		} else {
			links[pagetitle] = [a];
		}
		a.addEventListener('click', function(e) {
			if (e.shiftKey) {
				e.preventDefault();
				if (this.classList.contains(selectedLinkClass)) {
					links[pagetitle].forEach(function(l) {
						l.classList.remove(selectedLinkClass);
					});
					pages = pages.filter(function(p) { return p !== pagetitle; });
					linkCnt.textContent = pages.length.toString();
				}
			} else if (!e.ctrlKey) {
				e.preventDefault();
				if (!this.classList.contains(selectedLinkClass)) {
					links[pagetitle].forEach(function(l) {
						l.classList.add(selectedLinkClass);
					});
					pages.push(pagetitle);
					linkCnt.textContent = pages.length.toString();
				}
			}
		});

	});

	var $dialog = $(dialog);
	mw.hook('wikipage.content').fire($dialog);
	$dialog.dialog({
		resizable: false,
		draggable: true,
		height: 'auto',
		// @ts-ignore
		width: $(window).width() * 0.8,
		position: {
			my: 'center',
			at: 'center',
			of: window
		},
		modal: true,
		buttons: [{
			text: '選択終了',
			click: function() {
				$(this).dialog('close');
			}
		}],
		close: function() {
			def.resolve(pages);
			$(this).dialog('destroy').remove();
		}
	});

	return def.promise();

}

/**
 * Scrape the content of a wikipage from a pagetitle and additional query parameters.
 * @param {string} queryParam URL query params following '/w/index.php?title='
 * @returns {JQueryPromise<string|null|undefined>} Returns null if the page doesn't exist, undefined if the fetching fails.
 */
function scrapeWikipage(queryParam) {
	var def = $.Deferred();
	var url = mw.config.get('wgServer') + mw.config.get('wgScript') + '?title=' + queryParam;
	$.get(url)
		.then(function(res) {
			if (!res) return def.resolve(undefined);
			var html = document.createElement('html');
			html.innerHTML = res;
			var body;
			if (html.querySelector('.page-メインページ')) { // If the page doesn't exist, the content of Main page is returned
				return def.resolve(null);
			} else if ((body = html.querySelector('.mw-body-content'))) {
				return def.resolve(body.innerHTML);
			} else {
				return def.resolve(undefined);
			}
		})
		.catch(function(err) {
			console.log(MP, err);
			return def.resolve(undefined);
		});
	return def.promise();
}

/**
 * Get the content of a given page.
 * @param {string} pagename
 * @param {boolean} [parseHtml] Returns HTML if true.
 * @returns {JQueryPromise<string|null|undefined>} Returns null if the page doesn't exist, undefined if the fetching fails.
 */
function read(pagename, parseHtml) {

	var prop = !!parseHtml ? 'text' : 'wikitext';
	var params = {
		action: 'parse',
		page: pagename,
		prop: prop,
		formatversion: '2'
	};
	if (parseHtml) {
		$.extend(params, {
			disablelimitreport: true,
			disableeditsection: true,
			disabletoc: true,
			preview: true
		});
	}

	return api.get(params)
		.then(function(res) {
			var resParse;
			return res && res.parse && (resParse = res.parse[prop]) !== undefined ? resParse : undefined;
		})
		.catch(function(code, err) {
			console.log(MP, err);
			return code === 'missingtitle' ? null : undefined;
		});

}

/**
 * Get the delete reason dropdown as an HTMLSelectElement.
 * @returns {JQueryPromise<HTMLSelectElement|undefined>}
 */
function getProtectReasonDropdown() {

	var msgName = 'protect-dropdown';
	var query = function() {
		return api.getMessages([msgName])
			.then(function(res) {
				return res && res[msgName] ? res[msgName] : undefined;
			})
			.catch(function(code, err) {
				console.error(MP, err);
				return undefined;
			});
	};

	return query().then(function(msg) {

		if (!msg) return;

		var wrapper = document.createElement('select');
		wrapper.innerHTML =
			'<optgroup label="その他の理由">' +
				'<option value="">その他の理由</option>' +
			'</optgroup>';

		var regex = /(\*+)([^*]+)/g;
		var m, optgroup;
		while ((m = regex.exec(msg))) {
			if (m[1].length === 1) {
				optgroup = document.createElement('optgroup');
				optgroup.label = m[2].trim();
				wrapper.appendChild(optgroup);
			} else {
				var opt = document.createElement('option');
				opt.textContent = m[2].trim();
				if (optgroup) {
					optgroup.appendChild(opt);
				} else {
					wrapper.appendChild(opt);
				}
			}
		}
		return wrapper;

	});
}

/**
 * Get a list of vandalism-in-progress and long-term-abuse shortcuts in the form of wikilinks.
 * @returns {JQueryPromise<string[]>}
 */
function getAutocompleteSource() {

	/**
	 * @returns {JQueryPromise<string[]>}
	 */
	var getVipList = function() {

		// Parse section titles of the page that lists VIPs
		return api.get({
			action: 'parse',
			page: 'Wikipedia:進行中の荒らし行為',
			prop: 'sections',
			formatversion: '2'
		}).then(function(res) {

			var resSect;
			if (!res || !res.parse || !(resSect = res.parse.sections) || !resSect.length) return [];

			// Define sections tiltles that are irrelevant to VIP names
			var excludeList = [
				'記述について',
				'急を要する二段階',
				'配列',
				'ブロック等の手段',
				'このページに利用者名を加える',
				'注意と選択',
				'警告の方法',
				'未登録(匿名・IP)ユーザーの場合',
				'登録済み(ログイン)ユーザーの場合',
				'警告中',
				'関連項目'
			];

			// Return links like '[[WP:VIP#NAME]]'
			return resSect.reduce(/** @param {string[]} acc */ function(acc, obj) {
				if (excludeList.indexOf(obj.line) === -1 && obj.level == 3) {
					acc.push('[[WP:VIP#' + obj.line + ']]');
				}
				return acc;
			}, []);

		}).catch(function(code, err) {
			console.log(MP, 'Failed to get a VIP list.', err);
			return [];
		});

	};

	/**
	 * @returns {JQueryPromise<string[]>}
	 */
	var getLtaList = function() {

		var ltalist = [];
		/**
		 * @param {string} [apcontinue]
		 * @returns {JQueryPromise<undefined>}
		 */
		var query = function(apcontinue) { // There might be more than 500 LTA shortcuts and if so, API queries need to be repeated

			// Get all page titles that start with 'LTA:'
			var params = {
				action: 'query',
				list: 'allpages',
				apprefix: 'LTA:',
				apnamespace: '0',
				apfilterredir: 'redirects',
				aplimit: 'max',
				formatversion: '2'
			};
			if (apcontinue) params.apcontinue = apcontinue;

			return api.get(params)
				.then(function(res) {

					var resPages;
					if (!res || !res.query || !(resPages = res.query.allpages) || !resPages.length) return;

					resPages.forEach(function(obj) {
						if (/^LTA:[^/]+$/.test(obj.title)) ltalist.push('[[' + obj.title + ']]'); // Push '[[LTA:NAME]]'
					});

					var resCont;
					return res.continue && (resCont = res.continue.apcontinue) ? query(resCont) : undefined;

				})
				.catch(function(code, err) {
					console.log(MP, 'Failed to get an LTA list.', err);
					return undefined;
				});

		};

		// Return an array when the queries are done
		return query().then(function() {
			return ltalist;
		});

	};

	// Run the asynchronous functions defined above
	var deferreds = [];
	deferreds.push(getVipList(), getLtaList());
	return $.when.apply($, deferreds).then(function(viplist, ltalist) {
		return viplist.concat(ltalist); // Return a merged array
	});

}

/**
 * @typedef ActionProtectDefaultParams
 * @type {{
 *	action: "protect";
 *	reason: string;
 *	tags?: string;
 *	cascade: boolean;
 *	watchlist: "nochange"|"preferences"|"unwatch"|"watch";
 *	watchlistexpiry?: string;
 *	curtimestamp: boolean;
 *	formatversion: "2";
 * }}
 */
/**
 * @typedef ExtendedParams
 * @type {{
 *	title: string;
 *	protections: string;
 *	expiry: string;
 * }}
 */
/**
 * @typedef ActionProtectParams
 * @type {ActionProtectDefaultParams & ExtendedParams}
 */
/**
 * An object of pagetitle-object pairs.
 * @typedef ActionProtectParamsPerTitle
 * @type {Object.<string, ActionProtectParamsPerTitleVal>}
 */
/**
 * @typedef ActionProtectParamsPerTitleVal
 * @type {object}
 * @property {ActionProtectParams} [params] Undefined if the page is not to be forwarded to action=protect
 * @property {HTMLLIElement} list
 * @property {HTMLSpanElement} progress
 * @property {HTMLSpanElement} [progress2] Has a value only if a protection tag is to be modified
 * @property {boolean} unprotect If true, a pp tag (if there's any) is to be removed and there's no need to confirm overwriting
 * @property {"confirmall"|"confirmprotect"|"proceed"} overwrite Whether to ask before overwriting the current protection settings
 * @property {RelativeExpiryDuration} [relative] Relative watchlist expiry. This property has a value only if 'add to watchlist' is
 * checked && this page is to be protected && 'relative expiry' is checked && the watchlist expiry isn't indefinite.
 */
/**
 * @typedef RelativeExpiryDuration
 * @type {"1 week"|"2 weeks"|"1 month"|"3 months"|"6 months"|"1 year"}
 */
/**
 * @typedef PpOptions
 * @type {object}
 * @property {false|string} addPp A string of a pp template only if the 'add Pp' option is enabled.
 * @property {boolean} removePp Whether to remove pp on unprotection
 * @property {boolean} removeRfp Whether to remove RFP templates. No need to look at this if the addPp property is a string.
 */

/**
 * Execute MassProtect.
 * @param {ActionProtectParamsPerTitle} paramObj
 * @param {PpOptions} ppOptions
 */
function massProtect(paramObj, ppOptions) {

	/**
	 * Update the display of a progress span.
	 * @param {HTMLSpanElement} span
	 * @param {string|boolean} err One of the following: string (error message), true (unknown error), or false (success)
	 * @param {boolean} [cancelled] When true, show 'cancelled (err)'. The 'err' param must be a string.
	 */
	var updateProgress = function(span, err, cancelled) {
		if (cancelled) {
			if (typeof err !== 'string') {
				console.error(MP, 'The value of "err" must be a string when "cancelled" is true.');
			}
			span.innerHTML = getIcon('cancelled').outerHTML + ' 中止' + (err ? ' (' + err + ')' : '');
		} else if (typeof err === 'string') {
			span.innerHTML = getIcon('failed').outerHTML + ' 失敗 (' + err + ')';
		} else if (err) {
			span.innerHTML = getIcon('failed').outerHTML + ' 失敗 (不明なエラー)';
		} else {
			span.innerHTML = getIcon('done').outerHTML + ' 成功';
		}
	};

	/**
	 * @typedef ApiResponseProtectionSuccess
	 * @type {object}
	 * @property {string} curtimestamp
	 * @property {Object.<string, string>[]} protections
	 */
	/**
	 * @param {ActionProtectParams} params
	 * @returns {JQueryPromise<string|ApiResponseProtectionSuccess>} Error message string on failure, otherwise a formatted object
	 */
	var protectPage = function(params) {
		return api.postWithToken('csrf', params)
			.then(function(res) {
				console.log(MP, res);
				var resProt;
				if (res && res.protect && (resProt = res.protect.protections)) {
					return {
						curtimestamp: res.curtimestamp,
						protections: resProt
					};
				} else {
					return '不明なエラー';
				}
			})
			.catch(function(code, err) {
				console.log(MP, err);
				// @ts-ignore
				return err && err.error && err.error.info ? err.error.info : code;
			});
	};

	/**
	 * @param {ApiResponseProtectionSuccess} prot
	 * @param {RelativeExpiryDuration} duration
	 * @returns {string}
	 */
	var getRelativeExpiry = function(prot, duration) {

		if (!prot.protections.length) return 'indefinite';

		// Get the longest expiry
		/** @type {Date} */
		var newestD;
		for (var i = 0; i < prot.protections.length; i++) {
			var exp = prot.protections[i].expiry;
			if (/^in/.test(exp)) {
				return 'indefinite';
			} else {
				// @ts-ignore
				if (!newestD || newestD < new Date(exp)) {
					newestD = new Date(exp);
				}
			}
		}

		// @ts-ignore
		var relativeExpD = newestD;
		switch (duration) {
			case '1 week':
				relativeExpD.setDate(relativeExpD.getDate() + 7);
				break;
			case '2 weeks':
				relativeExpD.setDate(relativeExpD.getDate() + 14);
				break;
			case '1 month':
				relativeExpD.setMonth(relativeExpD.getMonth() + 1);
				break;
			case '3 months':
				relativeExpD.setMonth(relativeExpD.getMonth() + 3);
				break;
			case '6 months':
				relativeExpD.setMonth(relativeExpD.getMonth() + 6);
				break;
			case '1 year':
				relativeExpD.setFullYear(relativeExpD.getFullYear() + 1);
		}

		var max = new Date(prot.curtimestamp);
		max.setFullYear(max.getFullYear() + 1);
		if (relativeExpD > max) {
			return 'indefinite';
		} else {
			return relativeExpD.toJSON();
		}

	};

	/**
	 * @param {string} pagetitle
	 * @returns {JQueryPromise<{redirect: boolean; content: string; basetimestamp: string; starttimestamp: string;}|string>}
	 * Returns a string in the case of some error.
	 */
	var getLatestRevision = function(pagetitle) {
		return api.get({
			action: 'query',
			titles: pagetitle,
			prop: 'info|revisions',
			rvprop: 'content',
			rvslots: 'main',
			curtimestamp: 1,
			formatversion: '2'
		}).then(function(res) {

			var resPages;
			if (!res || !res.query || !(resPages = res.query.pages) || !resPages.length) {
				return '不明なエラー';
			}

			var resObj = resPages[0];

			return {
				redirect: !!resObj.redirect,
				content: resObj.revisions[0].slots.main.content,
				basetimestamp: resObj.touched,
				starttimestamp: res.curtimestamp
			};

		}).catch(function(code, err) {
			console.log(MP, err);
			return code;
		});
	};

	/**
	 * @param {string} pagetitle
	 * @param {boolean} unprotect
	 * @returns {JQueryPromise<{
	 *	result: "success"|"fail"|"cancel";
	 *	message: string;
	 * }>}
	 */
	var modifyPp = function(pagetitle, unprotect) {
		var def = $.Deferred();
		getLatestRevision(pagetitle).then(function(obj) {

			if (typeof obj === 'string') { // Failed to get the latest revision
				return def.resolve({result: 'fail', message: obj});
			}
			var content = obj.content;
			var originalContent = obj.content;

			// Get templates to remove
			/** @type {(name: string) => boolean} */
			var namePredicate;
			if (!unprotect && !ppOptions.addPp && ppOptions.removeRfp) {
				namePredicate = function(name) {
					return name === '保護依頼';
				};
			} else {
				var replacees = ['Pp', '保護', '半保護', '拡張半保護', '保護依頼', '保護運用', '移動保護', '移動拡張半保護'];
				namePredicate = function(name) {
					return /^Pp-/.test(name) || replacees.indexOf(name) !== -1;
				};
			}
			var templates = parseTemplates(content, {namePredicate: namePredicate});

			// Remove the templates (and a trailing line break), and also remove empty noinclude tags if the resulting content has any
			if (templates.length) {
				(function() {

					// Temporarily remove strip markers that prevent transclusion
					var stripMarkersRegex = /<!--[\s\S]*?-->|<(nowiki|pre|syntaxhighlight|source|math)[\s\S]*?<\/\1\s*>/gi;
					var idx = 0;
					/** @type {string[]} */
					var sm = [];
					var m;
					while ((m = stripMarkersRegex.exec(content))) {
						content = content.replace(m[0], '$SM' + (idx++));
						sm.push(m[0]);
					}

					// Template removal
					templates.forEach(function(Template) {
						content = content.replace(new RegExp(mw.util.escapeRegExp(Template.text) + '\\n?'), '');
					});
					content = content.replace(/<noinclude>\s*?<\/noinclude>[^\S\n\r]*\n?|\/\*\s*?\*\/[^\S\n\r]*\n?/gm, '');

					// Get strip markers back
					sm.forEach(function(marker, i) {
						content = content.replace('$SM' + i, marker);
					});

				})();
			}

			// Insert Pp
			if (ppOptions.addPp && !unprotect) {

				var pp = ppOptions.addPp;
				if (obj.redirect) { // If the page is a redirect

					content = content.trim(); // Redirects might have leading line breaks

					// Insert pp in the second line
					if (content.indexOf('\n') === -1) { // If the redirect doesn't have a line break
						content += '\n' + pp;
					} else {
						content = content.replace('\n', '\n' + pp + '\n');
					}

				} else {

					var nsNum = getNamespaceNumber(pagetitle);
					var isWikipedia = nsNum === 4;
					var isTemplate = nsNum === 10;
					var bool;

					// On a CSS page in the Template namespace
					if (isTemplate && /\.css$/.test(pagetitle)) {

						// Insert a commented-out pp
						content = '/* ' + pp + ' */' + '\n' + content;

					// On a template or on a subpage in the Wikipedia namespace
					} else if (isTemplate || (bool = isWikipedia && pagetitle.indexOf('/') !== -1)) {

						// Prevent transclusion
						content = '<noinclude>' + pp + '</noinclude>' + (bool ? '\n' : '') + content;

					// On ordinary pages, just prepend the pp
					} else {
						content = pp + '\n' + content;
					}

				}

			}

			// Don't edit page if the content is the same
			if (content === originalContent) {
				var msg;
				if (unprotect) {
					msg = '保護タグなし';
				} else {
					if (ppOptions.addPp) {
						msg = '同じ内容';
					} else {
						msg = '保護依頼タグなし';
					}
				}
				return def.resolve({result: 'cancel', message: msg});
			}

			// Edit page
			var summary;
			if (unprotect) {
				summary = '[[Template:Pp|保護テンプレート]]の除去';
			} else {
				if (ppOptions.addPp) {
					summary = ppOptions.addPp;
				} else {
					summary = '-{{保護依頼}}';
				}
			}
			api.postWithEditToken({
				action: 'edit',
				title: pagetitle,
				text: content,
				summary: summary,
				minor: 1,
				basetimestamp: obj.basetimestamp,
				starttimestamp: obj.starttimestamp,
				nocreate: 1,
				watchlist: 'nochange',
				// @ts-ignore
				tags: mw.config.get('wgDBname') === 'jawiki' ? MP : undefined,
				formatversion: '2'
			}).then(function(res) {
				if (res && res.edit && res.edit.result === 'Success') {
					def.resolve({result: 'success', message: ''});
				} else {
					def.resolve({result: 'fail', message: '不明なエラー'});
				}
			}).catch(function(code, err) {
				console.log(MP, err);
				// @ts-ignore
				def.resolve({result: 'fail', message: err && err.error && err.error.info ? err.error.info : code});
			});

		});
		return def.promise();
	};

	var pagetitles = Object.keys(paramObj);

	/**
	 * Execute protection and template pasting for a page. The titles in the list are processed sequentially.
	 * @param {number} index
	 */
	var execute = function(index) {

		var page = pagetitles[index];
		if (!page) { // When the index exceeds the length of the pagetitle array after recursive calls of this function
			mw.notify('処理が完了しました', {type: 'success'});
			return;
		}

		var params = paramObj[page].params;
		var progress = paramObj[page].progress;
		var progress2 = paramObj[page].progress2;
		var unprotect = paramObj[page].unprotect;
		var overwrite = paramObj[page].overwrite;
		var relative = paramObj[page].relative;
		paramObj[page].list.scrollIntoView(false); // Make sure that this list item is visible at the bottom of the page list

		// API parameters not having been prepared means that this page should be skipped
		if (!params) {
			updateProgress(progress, 'スキップ対象', true);
			if (progress2) {
				updateProgress(progress2, '', true);
			}
			execute(++index);
			return;
		}

		// Get the current protection settings. This could be done all at once for all the pages at the beginning of the procedure,
		// but list=logevents is limited to one page and if the target pages are too many, an ERR_INSUFFICIENT_RESOURCES error may result.
		getProtectionLogs([page]).then(function(status) {

			var s = status[page];

			// Skip if the page is to be unprotected but it's not protected
			if (unprotect && s && !s.current) {
				updateProgress(progress, '保護設定なし', true);
				if (progress2) {
					updateProgress(progress2, '', true);
				}
				execute(++index);
				return;
			}

			// Check whether we need to overwrite protection settings
			/** @returns {JQueryPromise<boolean>} */
			var proceed = function() {
				if (overwrite !== 'proceed') {
					var dialog;
					if (!s) { // Failed to fetch the current protection status; ask the user to check it manually
						dialog =
							'<div>' +
								'<p>' +
									'現在の保護設定の取得に失敗しました。続行するか手動で確認してください。<br>' +
									'<a href="' + mw.util.getUrl(page) + '" target="_blank">' + page + '</a> (' +
									'<a href="' + mw.util.getUrl(page, {action: 'history'}) + '" target="_blank">履歴</a> | ' +
									'<a href="' + mw.util.getUrl('Special:Log/protect', {page: page}) + '" target="_blank">保護記録</a> | ' +
									'<a href="' + mw.util.getUrl(page, {action: 'info'}) + '" target="_blank">情報</a>)' +
								'</p>' +
							'</div>';
						return dConfirm($(dialog));
					} else if (s.current) { // Page currently protected. Show information and ask the user whether to proceed
						if (unprotect) {
							if (overwrite === 'confirmall') { // Confirm both protect and unprotect
								dialog =
								'<div>' +
									'<p>' +
										'「' + page + '」の保護を<b>解除</b>します<br>' +
										'<br>保護者: ' + s.current.user +
										'<br>保護日時: ' + s.current.timestamp.replace(/Z$/, '') +
										'<br>現在時刻: ' + new Date().toJSON().replace(/\.\d{3}Z$/, '') +
										'<br>保護設定: ' + s.current.level.join(', ') +
										'<br>保護理由: ' + s.current.reason +
										'<br><br>よろしいですか?' +
									'</p>' +
								'</div>';
								return dConfirm($(dialog));
							} else { // overwrite === 'confirmprotect'
								return $.Deferred().resolve(true);
							}
						} else {
							// Ask the user to overwrite current protection both on overwrite === 'confirmall' and overwrite === 'confirmprotect'
							dialog =
							'<div>' +
								'<p>' +
									'「' + page + '」は既に保護されています<br>' +
									'<br>保護者: ' + s.current.user +
									'<br>保護日時: ' + s.current.timestamp.replace(/Z$/, '') +
									'<br>現在時刻: ' + new Date().toJSON().replace(/\.\d{3}Z$/, '') +
									'<br>保護設定: ' + s.current.level.join(', ') +
									'<br>保護理由: ' + s.current.reason +
									'<br><br>保護設定を上書きしますか?' +
								'</p>' +
							'</div>';
							return dConfirm($(dialog));
						}
					} else { // Page currently not protected; just proceed
						return $.Deferred().resolve(true);
					}
				} else { // The confirm-to-overwrite option is disabled; no pre-check before page protection
					return $.Deferred().resolve(true);
				}
			};

			proceed().then(function(proceedToProtect) {

				if (!proceedToProtect) { // User has chosen to not overwrite protection settings
					updateProgress(progress, '上書き防止', true);
					if (progress2) {
						updateProgress(progress2, '', true);
					}
					execute(++index);
					return;
				}

				// @ts-ignore "params" can't be undefined here
				protectPage(params).then(function(result) { // Send an API request to (un)protect the page

					// Show result
					if (typeof result === 'string') { // Failed
						updateProgress(progress, result);
					} else { // Succeeded
						updateProgress(progress, false);
						if (relative) {
							api.watch(page, getRelativeExpiry(result, relative))
								.then(function(res) {
									console.log(MP, res);
								})
								.catch(function(code, err) {
									console.log(MP, err);
								});
						}
					}

					// Check whether to end here or proceed to template modification
					if (!progress2) { // If no pp tag is to be modified
						execute(++index); // End here and go for next title
						return;
					} else { // If pp tag is to be modified
						if (typeof result === 'string') { // End here if protection failed
							updateProgress(progress2, '保護' + (unprotect ? '解除' : '') + '失敗', true);
							execute(++index);
							return;
						}
					} // Else, proceed

					modifyPp(page, unprotect).then(function(res) {
						switch (res.result) {
							case 'success':
								// @ts-ignore progress2 can't be undefined
								updateProgress(progress2, false);
								break;
							case 'fail':
								// @ts-ignore
								updateProgress(progress2, res.message);
								break;
							case 'cancel':
								// @ts-ignore
								updateProgress(progress2, res.message, true);
						}
						execute(++index);
					});

				});

			});

		});

	};

	execute(0);

}

/**
 * An alternative to window.confirm, by a dialog.
 * @param {JQuery<HTMLDivElement>} $dialog
 * @returns {JQueryPromise<boolean>}
 */
function dConfirm($dialog) {
	var def = $.Deferred();
	var bool = false;
	$dialog.prop('title', MP + ' - Confirm');
	$dialog.dialog({
		resizable: false,
		height: 'auto',
		width: 'auto',
		minWidth: 500,
		modal: true,
		position: {
			my: 'center',
			at: 'center',
			of: window
		},
		buttons: [
			{
				text: 'はい',
				click: function() {
					bool = true;
					$(this).dialog('close');
				}
			},
			{
				text: 'いいえ',
				click: function() {
					$(this).dialog('close');
				}
			}
		],
		close: function() {
			def.resolve(bool);
			$dialog.dialog('destroy').remove();
		}
	});
	return def.promise();
}

})();
//</nowiki>