コンテンツにスキップ

英文维基 | 中文维基 | 日文维基 | 草榴社区

利用者:Dragoniez/scripts/test.js

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

多くの WindowsLinux のブラウザ

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

Mac における Safari

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

Mac における ChromeFirefox

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

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

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

	Selective Rollback

	@author [[User:Dragoniez]]
	@version 4.0.0
	@see https://meta.wikimedia.org/wiki/User:Dragoniez/Selective_Rollback

	Some functionalities of this script are adapted from:
	@link https://meta.wikimedia.org/wiki/User:Hoo_man/smart_rollback.js
	@link https://en-two.iwiki.icu/wiki/User:DannyS712/AjaxRollback.js

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

/* global mw, OO */
/* eslint-disable @typescript-eslint/no-this-alias */
//<nowiki>
(function() {

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

	/**
	 * The parent node of rollback links and checkboxes, "li" or "#mw-diff-ntitle2". On RCW, the value is `null`.
	 * @typedef {"li"|"#mw-diff-ntitle2"|null} ParentNode
	 */
	/**
	 * Initialize the script.
	 */
	function init() {
		$.when(
			mw.loader.using(['mediawiki.api', 'mediawiki.util', 'jquery.ui', 'oojs-ui-core', 'oojs-ui-windows']),
			$.ready
		).then(function() {

			var $rbspans = $('.mw-rollback-link:visible');
			if (!$rbspans.length) return;

			api = new mw.Api();

			var spName = mw.config.get('wgCanonicalSpecialPageName');
			var onRCW = typeof spName === 'string' && ['Recentchanges', 'Watchlist'].indexOf(spName) !== -1;
			var /** @type {ParentNode} */ parentNode = (function() {
				if (onRCW) {
					return null;
				} else if (mw.config.get('wgAction') === 'history' || spName === 'Contributions') {
					return 'li';
				} else if (typeof mw.config.get('wgDiffNewId') === 'number') {
					return '#mw-diff-ntitle2';
				} else {
					var err = '[SR] Parent node could not be defined';
					mw.notify(err, {type: 'error'});
					throw new Error(err);
				}
			})();

			getMetaInfo().then(function(info) {
				var SR = SRFactory($rbspans, parentNode, info);
				new SR();
			});

		});

	}

	/**
	 * @typedef MetaInfo
	 * @type {object}
	 * @property {string} summary The raw `revertpage` message.
	 * @property {string} parsedsummary The `revertpage` message with {{PLURAL}} margic words parsed.
	 * @property {boolean} fetched Whether the default rollback summary was fetched.
	 * @property {string[]} rights The current user's user rights.
	 */
	/**
	 * Get the default rollback summary for the local wiki and the current user's user rights.
	 * @returns {JQueryPromise<MetaInfo>}
	 */
	function getMetaInfo() {
		return api.get({
			action: 'query',
			meta: 'allmessages|userinfo',
			ammessages: 'revertpage',
			amlang: mw.config.get('wgContentLanguage'),
			uiprop: 'rights',
			formatversion: '2'
		}).then(function(res) {
			/** @type {string=} */
			var summary = res && res.query && res.query.allmessages && res.query.allmessages[0] && res.query.allmessages[0].content;
			/** @type {string[]} */
			var rights = res && res.query && res.query.userinfo && res.query.userinfo.rights || [];
			return {
				summary: summary,
				rights: rights
			};
		}).catch(function(_, err) {
			console.log(err);
			return {
				summary: void 0,
				rights: []
			};
		}).then(/** @param {{summary: string|undefined; rights: string[]}} res */ function(res) {

			var fetched = !!res.summary;
			var summary = res.summary || 'Reverted edits by [[Special:Contributions/$2|$2]] ([[User talk:$2|talk]]) to last revision by [[User:$1|$1]]';
			var parsedsummary = summary;

			// Parse {{PLURAL}}
			var rPlural = /\{\{PLURAL:\$7\|(.+?)(?:\|(.*?))\}\}/gi;
			var m;
			while ((m = rPlural.exec(parsedsummary))) {
				parsedsummary = parsedsummary.replace(m[0], m[2] || m[1]);
			}

			return {
				summary: summary,
				parsedsummary: parsedsummary,
				fetched: fetched,
				rights: res.rights
			};

		});
	}

	/**
	 * Return the SR class.
	 * @param {JQuery<HTMLSpanElement>} $rbspans
	 * @param {ParentNode} parentNode
	 * @param {MetaInfo} meta
	 * @returns
	 */
	function SRFactory($rbspans, parentNode, meta) {

		var rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]+/g;

		// Initialize config setttings
		/**
		 * @typedef SelectiveRollbackConfig
		 * @property {string} lang
		 * @property {Record<string, string>} editSummaries
		 * @property {boolean} showKeys
		 * @property {Record<string, string>} specialExpressions
		 * @property {boolean} markBot
		 * @property {boolean} watchPage
		 * @property {string} watchExpiry
		 * @property {"never"|"always"|"RCW"|"nonRCW"} confirm
		 * @property {string} checkboxLabelColor
		 */
		/** @type {SelectiveRollbackConfig} @readonly */
		var SRC = (function() {

			var /** @type {SelectiveRollbackConfig} @readonly */ ret = {
				lang: '',
				editSummaries: {},
				showKeys: false,
				specialExpressions: {},
				markBot: true,
				watchPage: false,
				watchExpiry: 'indefinite',
				confirm: 'never',
				checkboxLabelColor: 'orange'
			};

			// Sanitize and merge user config
			/**
			 * Check whether a config value is of the expected type.
			 * @param {"string"|"number"|"bigint"|"boolean"|"symbol"|"undefined"|"object"|"function"|"null"} expectedType
			 * @param {any} val
			 * @param {string} key
			 * @returns {boolean}
			 */
			var isOfType = function(expectedType, val, key) {
				var valType = val === null ? 'null' : typeof val;
				if (valType !== expectedType) {
					console.error('[SR] TypeError: ' + expectedType + ' expected for "' + key + '", but got ' + valType + '.');
					return false;
				} else {
					return true;
				}
			};
			// @ts-ignore
			var userCfg = window.selectiveRollbackConfig;
			if (typeof userCfg === 'object' && userCfg !== null) {
				Object.keys(userCfg).forEach(function(key) {

					key = key.replace(rUnicodeBidi, '').trim();
					var val = userCfg[key];

					// Strict type check
					var v;
					if (val === (v = null) || val === (v = undefined)) {
						console.error('[SR] The value ' + v + ' for "' + key + '" is invalid.');
						return;
					}
					switch (key) {
						case 'lang':
						case 'watchExpiry':
						case 'confirm':
						case 'checkboxLabelColor':
							if (!isOfType('string', val, key)) return;
							if (key === 'confirm' && ['never', 'always', 'RCW', 'nonRCW'].indexOf(val) === -1) {
								console.error('[SR] "' + val + '" isn\'t a valid value for "confirm".');
								return;
							}
							break;
						case 'editSummaries':
						case 'specialExpressions':
							if (!isOfType('object', val, key)) return;
							break;
						case 'showKeys':
						case 'markBot':
						case 'watchPage':
							if (!isOfType('boolean', val, key)) return;
							break;
						default:
							console.error('[SR] "' + key + '" isn\'t a valid config key.');
							return;
					}

					if (key === 'watchExpiry') { // Some typo fix
						var m;
						val = val.replace(rUnicodeBidi, '').trim();
						if (/^in|^never/.test(key)) {
							val = 'indefinite';
						} else if ((m = /^1\s*(week|month|year)/.exec(val))) {
							val = '1 ' + m[1];
						} else if ((m = /^([36])\s*month/.exec(val))) {
							val = m[1] + ' months';
						// } else if (/^3\s*year/.test(val)) {
						//     val = '3 years';
						} else {
							return console.error('[SR] "' + val + '" is not a valid watch-page expiry.');
						}
						userCfg[key] = val;
					}
					// @ts-ignore
					ret[key] = userCfg[key];

				});
			}

			return ret;

		})();

		// Language settings
		/**
		 * @typedef {"ja"|"en"|"zh"|"es"|"ro"} Languages
		 */
		/**
		 * @typedef {object} Messages
		 * @property {string} portletlink-tooltip Tooltip for the portlet link used to open the SR dialog.
		 * @property {string} summary-label-primary The label for the edit summary dropdown.
		 * @property {string} summary-option-default The text for the default edit summary dropdown option.
		 * @property {string} summary-option-custom The text for the custom edit summary dropdown option.
		 * @property {string} summary-label-custom The label for the custom edit summary inputbox.
		 * @property {string} summary-tooltip-$0 Tooltip that says $0 will be replaced with the default edit summary.
		 * @property {string} summary-tooltip-$0-error [Contains a \<b> tag]: Tooltip that says $0 will be replaced with
		 * the default edit summary **in English**.
		 * @property {string} summary-tooltip-specialexpressions The leading text for replacement expressions.
		 * @property {string} summary-label-preview The label for the summary preview div.
		 * @property {string} summary-tooltip-preview Tooltip that says magic words in previewed summary will be replaced.
		 * @property {string} markbot-label The label for the markbot checkbox.
		 * @property {string} watchlist-label The label for the watch-page checkbox.
		 * @property {string} watchlist-expiry-label The label for the watch-expiry dropdown.
		 * @property {string} watchlist-expiry-indefinite The text for the indefinite expiry dropdown option.
		 * @property {string} watchlist-expiry-1week The text for the 1-week expiry dropdown option.
		 * @property {string} watchlist-expiry-1month The text for the 1-month expiry dropdown option.
		 * @property {string} watchlist-expiry-3months The text for the 3-month expiry dropdown option.
		 * @property {string} watchlist-expiry-6months The text for the 6-month expiry dropdown option.
		 * @property {string} watchlist-expiry-1year The text for the 1-year expiry dropdown option.
		 * @property {string} watchlist-expiry-3years The text for the 3-year expiry dropdown option.
		 * @property {string} button-rollbackchecked The text for "rollbackchecked" dialog button.
		 * @property {string} button-checkall The text for "checkall" dialog button.
		 * @property {string} button-close The text for "close" dialog button.
		 * @property {string} msg-nonechecked A mw.notify message for when no checkbox is checked for selective rollback.
		 * @property {string} msg-linksresolved A mw.notify message for when there's no checkbox to check when the "checkall" button is hit.
		 * @property {string} msg-confirm An OO.ui.confirm message to confirm rollback.
		 * @property {string} rbstatus-reverted The text for reverted rollback links.
		 * @property {string} rbstatus-failed The text for non-reverted rollback links.
		 * @property {string} rbstatus-notify-success Internal text ("Success") for a mw.notify message that shows how many rollbacks succeeded.
		 * @property {string} rbstatus-notify-failure Internal text ("Failure") for a mw.notify message that shows how many rollbacks failed.
		 */
		var /** @type {Messages} */ msg = (function() {

			/** @type {Record<Languages, Messages>} @readonly */
			var i18n = {
				ja: {
					'portletlink-tooltip': 'Selective Rollbackのダイアログを開く',
					'summary-label-primary': '編集要約',
					'summary-option-default': '標準の編集要約',
					'summary-option-custom': 'カスタム',
					'summary-label-custom': 'カスタム編集要約',
					'summary-tooltip-$0': '($0は標準の編集要約に置換されます。)',
					'summary-tooltip-$0-error': '($0は<b>英語の</b>標準編集要約に置換されます。)',
					'summary-tooltip-specialexpressions': '置換表現',
					'summary-label-preview': '要約プレビュー', // v4.0.0
					'summary-tooltip-preview': '(マジックワードは置換されます)', // v4.0.0
					'markbot-label': 'ボット編集として巻き戻し',
					'watchlist-label': '対象ページをウォッチリストに追加',
					'watchlist-expiry-label': '期間',
					'watchlist-expiry-indefinite': '無期限',
					'watchlist-expiry-1week': '1週間',
					'watchlist-expiry-1month': '1か月',
					'watchlist-expiry-3months': '3か月',
					'watchlist-expiry-6months': '6か月',
					'watchlist-expiry-1year': '1年',
					'watchlist-expiry-3years': '3年', // Not used
					'button-rollbackchecked': 'チェック項目を巻き戻し',
					'button-checkall': '全てチェック',
					'button-close': '閉じる',
					'msg-nonechecked': 'チェックボックスがチェックされていません。',
					'msg-linksresolved': 'このページの巻き戻しリンクは全て解消済みです。',
					'msg-confirm': '巻き戻しを実行しますか?',
					'rbstatus-reverted': '巻き戻し済',
					'rbstatus-failed': '巻き戻し失敗',
					'rbstatus-notify-success': '成功', // v4.0.0
					'rbstatus-notify-failure': '失敗' // v4.0.0
				},
				en: {
					'portletlink-tooltip': 'Open the Selective Rollback dialog',
					'summary-label-primary': 'Edit summary',
					'summary-option-default': 'Default edit summary',
					'summary-option-custom': 'Custom',
					'summary-label-custom': 'Custom edit summary',
					'summary-tooltip-$0': '($0 will be replaced with the default rollback summary.)',
					'summary-tooltip-$0-error': '($0 will be replaced with the default rollback summary <b>in English</b>.)',
					'summary-tooltip-specialexpressions': 'Replacement expressions',
					'summary-label-preview': 'Summary preview', // v4.0.0
					'summary-tooltip-preview': '(Magic words will be replaced)', // v4.0.0
					'markbot-label': 'Mark rollbacks as bot edits',
					'watchlist-label': 'Add the target pages to watchlist',
					'watchlist-expiry-label': 'Expiry',
					'watchlist-expiry-indefinite': 'Indefinite',
					'watchlist-expiry-1week': '1 week',
					'watchlist-expiry-1month': '1 month',
					'watchlist-expiry-3months': '3 months',
					'watchlist-expiry-6months': '6 months',
					'watchlist-expiry-1year': '1 year',
					'watchlist-expiry-3years': '3 years', // Not used
					'button-rollbackchecked': 'Rollback checked',
					'button-checkall': 'Check all',
					'button-close': 'Close',
					'msg-nonechecked': 'No checkbox is checked.',
					'msg-linksresolved': 'Rollback links on this page have all been resolved.',
					'msg-confirm': 'Are you sure you want to rollback this edit?',
					'rbstatus-reverted': 'reverted',
					'rbstatus-failed': 'rollback failed',
					'rbstatus-notify-success': 'Success', // v4.0.0
					'rbstatus-notify-failure': 'Failure' // v4.0.0
				},
				zh: {
					'portletlink-tooltip': '打开Selective Rollback日志',
					'summary-label-primary': '编辑摘要',
					'summary-option-default': '默认编辑摘要',
					'summary-option-custom': '自定义',
					'summary-label-custom': '自定义编辑摘要',
					'summary-tooltip-$0': '($0将会被默认编辑摘要替代。)',
					'summary-tooltip-$0-error': '($0将会被默认编辑摘要为<b>英文</b>替代。)',
					'summary-tooltip-specialexpressions': '替换表达',
					'summary-label-preview': '编辑摘要的预览', // v4.0.0
					'summary-tooltip-preview': '(魔术字将被替换)', // v4.0.0
					'markbot-label': '标记为机器人编辑',
					'watchlist-label': '将目标页面加入监视页面',
					'watchlist-expiry-label': '时间',
					'watchlist-expiry-indefinite': '不限期',
					'watchlist-expiry-1week': '1周',
					'watchlist-expiry-1month': '1个月',
					'watchlist-expiry-3months': '3个月',
					'watchlist-expiry-6months': '6个月',
					'watchlist-expiry-1year': '1年',
					'watchlist-expiry-3years': '3年', // Not used
					'button-rollbackchecked': '勾选回退',
					'button-checkall': '全选',
					'button-close': '关闭',
					'msg-nonechecked': '未选择任何勾选框。',
					'msg-linksresolved': '与该页面相关的回退全部完成。',
					'msg-confirm': '您确定要回退该编辑吗?',
					'rbstatus-reverted': '已回退',
					'rbstatus-failed': '回退失败',
					'rbstatus-notify-success': '成功', // v4.0.0
					'rbstatus-notify-failure': '失败' // v4.0.0
				},
				/** @author [[User:64andtim]] */
				es: {
					'portletlink-tooltip': 'Abrir el diálogo Selective Rollback',
					'summary-label-primary': 'Resumen de edición',
					'summary-option-default': 'Resumen de edición automática',
					'summary-option-custom': 'Manual',
					'summary-label-custom': 'Resumen de edición manual',
					'summary-tooltip-$0': '($0 será reemplazada con la resumen de reversión automática.)',
					'summary-tooltip-$0-error': '($0 será reemplazada con la resumen de reversión automática <b>en inglés</b>.)',
					'summary-tooltip-specialexpressions': 'Expresiones de reemplazo',
					'summary-label-preview': 'Previsualización del resumen de edición', // v4.0.0
					'summary-tooltip-preview': '(Las palabras mágicas serán reemplazadas)', // v4.0.0
					'markbot-label': 'Marcar las reversiones cómo ediciones de un bot',
					'watchlist-label': 'Vigilar las páginas en tu lista de seguimiento',
					'watchlist-expiry-label': 'Tiempo',
					'watchlist-expiry-indefinite': 'Permanente',
					'watchlist-expiry-1week': 'una semana',
					'watchlist-expiry-1month': 'un mes',
					'watchlist-expiry-3months': 'tres meses',
					'watchlist-expiry-6months': 'seis meses',
					'watchlist-expiry-1year': 'un año',
					'watchlist-expiry-3years': 'tres años', // Not used
					'button-rollbackchecked': 'Revertir elegidos',
					'button-checkall': 'Elegir todos',
					'button-close': 'Cerrar',
					'msg-nonechecked': 'Ningún casilla fue elegida.',
					'msg-linksresolved': 'Todos los enlaces de reversión en esta página se han resuelto.',
					'msg-confirm': '¿Estás seguro que quieres revertir este edición?',
					'rbstatus-reverted': 'revertido',
					'rbstatus-failed': 'la reversión falló',
					'rbstatus-notify-success': 'Éxito', // v4.0.0
					'rbstatus-notify-failure': 'Falla' // v4.0.0
				},
				/** @author [[User:NGC 54]] */
				ro: {
					'portletlink-tooltip': 'Deschide dialogul Selective Rollback',
					'summary-label-primary': 'Descrierea modificării',
					'summary-option-default': 'Descrierea implicită a modificării',
					'summary-option-custom': 'Personalizat',
					'summary-label-custom': 'Descriere personalizată a modificării',
					'summary-tooltip-$0': '($0 va fi înlocuit cu descrierea implicită a revenirii.)',
					'summary-tooltip-$0-error': '($0 va fi înlocuit cu descrierea implicită a revenirii <b>în engleză</b>.)',
					'summary-tooltip-specialexpressions': 'Expresii de înlocuire',
					'summary-label-preview': 'Previzualizare descriere', // v4.0.0
					'summary-tooltip-preview': '(Cuvintele magice vor fi înlocuite)', // v4.0.0
					'markbot-label': 'Marchează revenirile drept modificări făcute de robot',
					'watchlist-label': 'Adaugă paginile țintă la pagini urmărite',
					'watchlist-expiry-label': 'Expiră',
					'watchlist-expiry-indefinite': 'Nelimitat',
					'watchlist-expiry-1week': '1 săptămână',
					'watchlist-expiry-1month': '1 lună',
					'watchlist-expiry-3months': '3 luni',
					'watchlist-expiry-6months': '6 luni',
					'watchlist-expiry-1year': '1 an',
					'watchlist-expiry-3years': '3 ani', // Not used
					'button-rollbackchecked': 'Revino asupra celor bifate',
					'button-checkall': 'Bifează tot',
					'button-close': 'Închide',
					'msg-nonechecked': 'Nu este bifată nicio căsuță bifabilă.',
					'msg-linksresolved': 'Toate legăturile de revenire de pe această pagină au fost utilizate.',
					'msg-confirm': 'Ești sigur(ă) că vrei să revii asupra acestei modificări?',
					'rbstatus-reverted': 'revenit',
					'rbstatus-failed': 'revenire eșuată',
					'rbstatus-notify-success': 'Succes', // v4.0.0
					'rbstatus-notify-failure': 'Eșec' // v4.0.0
				}
			};

			var langSwitch = SRC.lang || mw.config.get('wgUserLanguage'); // Fall back to the user's language specified in preferences
			if (['ja', 'zh', 'es', 'ro'].indexOf(langSwitch) !== -1) {
				return i18n[langSwitch];
			} else {
				if (SRC.lang && SRC.lang !== 'en') {
					console.error('[SR] Sorry, "' + SRC.lang + '" is unavaiable as the interface language.');
				}
				return i18n.en;
			}

		})();

		// Append <style>
		var style = document.createElement('style');
		style.textContent =
			'.sr-checkbox-wrapper {' +
				'display: inline-block;' +
			'}' +
			'.sr-checkbox {' +
				'margin-right: 0.5em;' +
			'}' +
			'.sr-rollback {' +
				'display: inline-block;' +
				'margin: 0 0.5em;' +
			'}' +
			'.sr-rollback-label {' +
				'font-weight: bold;' +
				'color: ' + SRC.checkboxLabelColor + ';' +
			'}' +
			'.sr-dialog-borderbox {' +
				'display: block;' +
				'width: 100%;' +
				'box-sizing: border-box;' +
				'border: 1px solid #777;' +
				'border-radius: 1%;' +
				'background-color: white;' +
				'padding: 2px 4px;' +
			'}';
		document.head.appendChild(style);

		/**
		 * @typedef {{$label: JQuery<HTMLLabelElement>; $checkbox: JQuery<HTMLInputElement>;}} Box
		 */
		/**
		 * Create a labeled checkbox.
		 * ```html
		 * <label class="sr-checkbox-wrapper">
		 * 	<input class="sr-checkbox" type="checkbox">
		 * 	<span>LABELTEXT</span>
		 * </label>
		 * ```
		 * @param {string} labelText
		 * @param {string} [textClassNames] Optional class names to apply to the text of the label.
		 * @returns {Box}
		 */
		var createCheckbox = function(labelText, textClassNames) {
			var /** @type {JQuery<HTMLLabelElement>} */ $label;
			var /** @type {JQuery<HTMLInputElement>} */ $checkbox;
			($label = $('<label>'))
				.addClass('sr-checkbox-wrapper')
				.append(
					($checkbox = $('<input>'))
						.prop({type: 'checkbox'})
						.addClass('sr-checkbox'),
					$('<span>')
						.text(labelText)
						.addClass(textClassNames || '')
				);
			return {$label: $label, $checkbox: $checkbox};
		};

		return /** @class */ (function() {

			/**
			 * Object that stores elements related to the SR checkbox.
			 * @typedef {{$wrapper: JQuery<HTMLSpanElement>;} & Box} SRBox
			 */
			/**
			 * Object that stores rollback links and their associated SR checkboxes.
			 * @typedef {Record<string, {rbspan: HTMLSpanElement; box: SRBox?;}>} Link
			 */
			/**
			 * Additional parameters to `action=rollback`.
			 * @typedef RollbackParams
			 * @property {string} summary An empty string will be altered with the default summary by the mediawiki software
			 * @property {boolean} markbot
			 * @property {"nochange"|"preferences"|"unwatch"|"watch"} watchlist Default: preferences
			 * @property {string=} watchlistexpiry
			 */
			/**
			 * Initialize an SR instance.
			 * @constructor
			 */
			function SR() {

				var _this = this;

				// Create dialog
				/**
				 * The SR dialog.
				 * @type {JQuery<HTMLDivElement>}
				 */
				this.$dialog = $('<div>');
				this.$dialog
					.prop({title: 'Selective Rollback'})
					.css({
						padding: '1em',
						maxWidth: '580px'
					}).dialog({
						dialogClass: 'sr-dialog',
						height: 'auto',
						width: 'auto',
						minWidth: 515,
						minHeight: 175,
						resizable: false,
						autoOpen: false,
						modal: true,
						buttons: (function() {
							var btns = [
								{	// "Rollback checked" button
									text: msg['button-rollbackchecked'],
									click: function() {
										_this.selectiveRollback();
									}
								},
								{	// "Check all" button
									text: msg['button-checkall'],
									click: function() {
										var cnt = 0;
										for (var key in _this.links) {
											var obj = _this.links[key];
											if (obj.box) {
												obj.box.$checkbox.prop('checked', true);
												cnt++;
											}
										}
										if (!cnt) {
											mw.notify(msg['msg-linksresolved'], {type: 'warn'});
										}
									}
								},
								{	// "Close" button
									text: msg['button-close'],
									click: function() {
										_this.close();
									}
								}
							];
							if (!parentNode) btns.splice(0, 2); // Only leave the "Close" button if the user is on RCW
							return btns;
						})()
					});

				// Property-related variables
				var /** @type {JQuery<HTMLSelectElement>} */ $summaryList;
				var /** @type {JQuery<HTMLInputElement>} */ $summary;
				var /** @type {JQuery<HTMLDivElement>} */ $summaryPreview;
				var /** @type {JQuery<HTMLDivElement>} */ $summaryPreviewTooltip;
				var botBox = createCheckbox(msg['markbot-label']);
				var watchBox = createCheckbox(msg['watchlist-label']);
				var /** @type {JQuery<HTMLUListElement>} */ $watchUl;
				var /** @type {JQuery<HTMLSelectElement>} */ $watchExpiry;

				// Intra-constructor variables
				var psId = 'sr-presetsummary';
				var csId = 'sr-customsummary';
				var /** @type {JQuery<HTMLOptionElement>} */ $psOptCustom;

				// Create dialog contents
				this.$dialog.append(
					// Preset summary wrapper
					$('<div>')
						.prop({id: 'sr-presetsummary-wrapper'})
						.css({marginBottom: '0.5em'})
						.append(
							$('<label>')
								.prop({htmlFor: psId})
								.text(msg['summary-label-primary']),
							($summaryList = $('<select>'))
								.prop({id: psId})
								.addClass('sr-dialog-borderbox')
								.append(
									$('<option>')
										.prop({
											id: 'sr-presetsummary-default',
											value: ''
										})
										.text(msg['summary-option-default']),
									(function() {
										// Append user-defined edit summaries if there's any
										var $options = $([]);
										if (!$.isEmptyObject(SRC.editSummaries)) {
											Object.keys(SRC.editSummaries).forEach(function(key) {
												$options = $options.add(
													$('<option>')
														.prop({value: SRC.editSummaries[key]})
														.text(SRC.showKeys ? key : SRC.editSummaries[key])
												);
											});
										}
										return $options;
									})(),
									($psOptCustom = $('<option>'))
										.prop({
											id: 'sr-presetsummary-custom',
											value: 'other'
										})
										.text(msg['summary-option-custom'])
								)
								.off('change').on('change', function() {
									_this.previewSummary();
								})
						),
					// Custom summary wrapper
					$('<div>')
						.prop({id: 'sr-customsummary-wrapper'})
						.css({marginBottom: '0.4em'})
						.append(
							$('<label>')
								.prop({htmlFor: csId})
								.text(msg['summary-label-custom']),
							($summary = $('<input>'))
								.prop({id: csId})
								.addClass('sr-dialog-borderbox')
								.off('focus').on('focus', function() {
									// When the custom summary field is focused, set the dropdown option to "other"
									var initiallySelected = $psOptCustom.is(':selected');
									$psOptCustom.prop('selected', true);
									if (!initiallySelected) {
										$summaryList.trigger('change');
									}
								})
								.off('input').on('input', function() {
									_this.previewSummary();
								}),
							$('<p>')
								.prop({
									id: 'sr-customsummary-$0',
									innerHTML: msg[meta.fetched ? 'summary-tooltip-$0' : 'summary-tooltip-$0-error']
								})
								.css({
									fontSize: 'smaller',
									margin: '0'
								}),
							$('<p>')
								.prop({id: 'sr-customsummary-$SE'})
								.css({
									fontSize: 'smaller',
									margin: '0',
									display: 'none'
								})
								.text(function() {
									// Show a list of special expressions if defined by the user
									if (!$.isEmptyObject(SRC.specialExpressions)) {
										var seTooltip = Object.keys(SRC.specialExpressions).join(', ');
										$(this).css({
											display: 'inline-block',
											marginBottom: '0'
										});
										return '(' + msg['summary-tooltip-specialexpressions'] + ': ' + seTooltip + ')';
									} else {
										return '';
									}
								})
						),
					// Summary preview wrapper
					$('<div>')
						.prop({id: 'sr-summarypreview-wrapper'})
						.append(
							document.createTextNode(msg['summary-label-preview']),
							($summaryPreview = $('<div>'))
								.prop({
									id: 'sr-summarypreview',
									readonly: true
								})
								.addClass('sr-dialog-borderbox')
								.css({backgroundColor: 'initial'}),
							($summaryPreviewTooltip =  $('<div>'))
								.prop({id: 'sr-summarypreview-tooltip'})
								.text(msg['summary-tooltip-preview'])
								.css({
									fontSize: 'smaller',
									marginTop: '0.4em',
									marginBottom: '0'
								})
								.hide()
						)
						.css({marginBottom: '0.5em'}),
					// Markbot option wrapper
					$('<div>')
						.prop({id: 'sr-bot-wrapper'})
						.append(botBox.$label)
						.css('display', function() {
							// Hide the checkbox if the user doesn't have "markbot" / check/uncheck the box in accordance with the config
							if (meta.rights.indexOf('markbotedits') !== -1) {
								botBox.$checkbox.prop('checked', SRC.markBot);
								return 'block';
							} else {
								return 'none';
							}
						}),
					// Watchlist option wrapper
					$('<div>')
						.prop({id: 'sr-watchlist-wrapper'})
						.append(
							watchBox.$label,
							($watchUl = $('<ul>'))
								.prop({id: 'sr-watchlist-expiry'})
								.hide()
								.append(
									$('<li>')
										.append(
											document.createTextNode(msg['watchlist-expiry-label']),
											($watchExpiry = $('<select>'))
												.prop({id: 'sr-watchlist-expiry-dropdown'})
												.css({marginLeft: '0.5em'})
												.append(
													[
														{value: 'indefinite', text: msg['watchlist-expiry-indefinite']},
														{value: '1 week', text: msg['watchlist-expiry-1week']},
														{value: '1 month', text: msg['watchlist-expiry-1month']},
														{value: '3 months', text: msg['watchlist-expiry-3months']},
														{value: '6 months', text: msg['watchlist-expiry-6months']},
														{value: '1 year', text: msg['watchlist-expiry-1year']}//,
														// {value: '3 years', text: msg['watchlist-expiry-3years']} // 1y is the max ATM; phab:T336142
													]
													.map(function(obj) {
														return $('<option>').prop('value', obj.value).text(obj.text);
													})
												)
												.val(SRC.watchExpiry)
										)
								)
						)
				);

				// Initialize the watchlist checkbox
				watchBox.$checkbox
					.off('change').on('change', function() {
						// Show/hide the expiry dropdown when the checkbox is (un)checked
						$watchUl.toggle($(this).is(':checked'));
					})
					.prop('checked', SRC.watchPage)
					.trigger('change');

				// Define properties
				/**
				 * The summary dropdown.
				 * @type {JQuery<HTMLSelectElement>}
				 */
				this.$summaryList = $summaryList;

				/**
				 * The summary input.
				 * @type {JQuery<HTMLInputElement>}
				 */
				this.$summary = $summary;

				/**
				 * The div for summary preview.
				 * @type {JQuery<HTMLDivElement>}
				 */
				this.$summaryPreview = $summaryPreview;

				/**
				 * The div for summary preview tooltip (which says "Magic words will be replaced").
				 * @type {JQuery<HTMLDivElement>}
				 */
				this.$summaryPreviewTooltip = $summaryPreviewTooltip;

				/**
				 * The markbox checkbox.
				 * @type {JQuery<HTMLInputElement>}
				 */
				this.$markbot = botBox.$checkbox;

				/**
				 * The watch-page checkbox.
				 * @type {JQuery<HTMLInputElement>}
				 */
				this.$watch = watchBox.$checkbox;

				/**
				 * The watch-expiry dropdown.
				 * @type {JQuery<HTMLSelectElement>}
				 */
				this.$watchExpiry = $watchExpiry;

				/**
				 * The portlet link to open the SR dialog.
				 * @type {HTMLLIElement?}
				 */
				this.portlet = mw.util.addPortletLink(
					mw.config.get('skin') === 'minerva' ? 'p-personal' : 'p-cactions',
					'#',
					'Selective Rollback',
					'ca-sr',
					msg['portletlink-tooltip'],
					void 0,
					'#ca-move'
				);
				if (this.portlet) {
					this.portlet.addEventListener('click', function(e) {
						e.preventDefault();
						_this.open();
					});
				} else {
					console.error('[SR] Failed to create a portlet link.');
				}

				/**
				 * An object of rollback spans and their associated SR checkboxes.
				 *
				 * Each rbspan has `data-sr-index` to be used to unbind the associated property from the class.
				 * @type {Link}
				 */
				this.links = Array.prototype.reduce.call(
					$rbspans,
					/**
					 * @param {Link} acc
					 * @param {HTMLSpanElement} rbspan
					 * @param {number} i
					 */
					function(acc, rbspan, i) {

						var $rbspan = $(rbspan);
						$rbspan.data('sr-index', i);

						// Add an SR checkbox
						var /** @type {SRBox?} */ box = null;
						if (parentNode && (box = SR.createCheckbox())) {
							$rbspan.closest(parentNode).append(box.$wrapper);
						}

						// Bind AJAX rollback as a click event
						$rbspan.off('click').on('click', function(e) {
							e.preventDefault();
							var rbspan = this;
							if (e.ctrlKey) {
								// If CTRL key is pressed down, just open the dialog, not executing rollback
								_this.open();
								return;
							} else if (
								// Confirm rollback per config
								!e.shiftKey && (
									SRC.confirm === 'always' ||
									parentNode && SRC.confirm === 'RCW' ||
									!parentNode && SRC.confirm === 'nonRCW'
								)
							) {
								OO.ui.confirm(msg['msg-confirm']).then(function(confirmed) {
									if (confirmed) _this.ajaxRollback(rbspan, box);
								});
							} else {
								_this.ajaxRollback(rbspan, box);
							}
						});

						acc[i] = {
							rbspan: rbspan,
							box: box
						};
						return acc;

					},
					Object.create(null)
				);

				// On jawp, set up autocomplete for the custom summary textbox
				var moduleName = 'ext.gadget.WpLibExtra';
				if (mw.config.get('wgWikiID') === 'jawiki' && mw.loader.getModuleNames().indexOf(moduleName) !== -1) {
					mw.loader.using(moduleName).then(function(require) {
						var /** @type {WpLibExtra} */ lib = require(moduleName);
						$.when(lib.getVipList('wikilink'), lib.getLtaList('wikilink')).then(function(vipList, ltaList) {
							var list = vipList.concat(ltaList);
							$summary.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));
								},
								select: function(_, ui) {
									// When the event is triggered, getSummary picks up the value before selection
									// Because of this, pick up the autocompleted value and pass it to previewSummary
									var /** @type {string?} */ val = ui.item && ui.item.value;
									if (val) {
										_this.previewSummary(val);
									}
								},
								position: {
									my: 'left bottom',
									at: 'left top'
								}
							});
						});
					});
				}

				// Initialize summary preview
				this.$summary.trigger('input');

			}

			/**
			 * Create an SR checkbox.
			 * @returns {SRBox}
			 * @static
			 */
			SR.createCheckbox = function() {
				var box = createCheckbox('SR', 'sr-rollback-label');
				var /** @type {JQuery<HTMLSpanElement>} */ $wrapper = $('<span>')
					.addClass('sr-rollback')
					.append(
						$('<b>').text('['),
						box.$label,
						$('<b>').text(']')
					);
				box.$checkbox.css({margin: '0 0.3em 0 0.2em'});
				return {$wrapper: $wrapper, $label: box.$label, $checkbox: box.$checkbox};
			};

			/**
			 * Open the SR dialog.
			 * @returns {SR}
			 */
			SR.prototype.open = function() {
				this.$dialog.dialog('open');
				return this;
			};

			/**
			 * Close the SR dialog.
			 * @returns {SR}
			 */
			SR.prototype.close = function() {
				this.$dialog.dialog('close');
				return this;
			};

			/**
			 * Get summary.
			 * @returns {string} Can return an empty string if the default option is selected or if the custom option is selected but the
			 * input for a custom summary is empty.
			 *
			 * Note that the default rollback summary is used by the mediawiki software if the `summary` parameter to `action=rollback`
			 * is unspecified or specified with an empty string.
			 */
			SR.prototype.getSummary = function() {
				var summary = this.$summaryList.val() === 'other' ? this.$summary[0].value.replace(rUnicodeBidi, '').trim() : this.$summaryList[0].value;
				if (summary === '$0') {
					// If the summary is customized but is only of "$0", let the mediawiki software use the default summary
					// (This leads to a better performance of magic word parsing)
					summary = '';
				} else {
					// Replace $0 with the default summary
					summary = summary.replace('$0', meta.parsedsummary);
				}
				if (!$.isEmptyObject(SRC.specialExpressions)) { // Replace special expressions as defined by the user
					for (var key in SRC.specialExpressions) {
						summary = summary.split(key).join(SRC.specialExpressions[key]);
					}
				}
				return summary;
			};

			/**
			 * Get the checked state of the markbot box.
			 * @returns {boolean}
			 */
			SR.prototype.getMarkBot = function() {
				return this.$markbot.is(':checked');
			};

			/**
			 * Get the checked state of the watch-page box, converted to a string value.
			 * @returns {"watch"|"nochange"}
			 */
			SR.prototype.getWatchlist = function() {
				return this.$watch.is(':checked') ? 'watch' : 'nochange';
			};

			/**
			 * Get the selected watchlist expiry.
			 * @returns {string=} `undefined` if the watch-page box isn't checked.
			 */
			SR.prototype.getWatchlistExpiry = function() {
				return this.$watch.is(':checked') && this.$watchExpiry[0].value || void 0;
			};

			/**
			 * Get parameters to `action=rollback`.
			 * @returns {RollbackParams}
			 */
			SR.prototype.getParams = function() {
				return {
					summary: this.getSummary(),
					markbot: this.getMarkBot(),
					watchlist: this.getWatchlist(),
					watchlistexpiry: this.getWatchlistExpiry()
				};
			};

			var /** @type {mw.Api} @readonly */ previewApi = new mw.Api();
			var /** @type {NodeJS.Timeout} */ previewTimeout;
			/**
			 * Preview summary.
			 * @param {string} [manualSummary] If not passed, the method calls `getSummary`.
			 * @returns {void}
			 */
			SR.prototype.previewSummary = function(manualSummary) {
				clearTimeout(previewTimeout);
				var _this = this;

				// Get summary to preview
				var summary = manualSummary || this.getSummary();
				var containsMagicWords = false;
				if (!summary) { // If the obtained summary is an empty string, preview the default summary
					summary = meta.summary; // Might contain magic words
					containsMagicWords = /\{\{plural:/i.test(summary);
				}

				// Preview
				previewTimeout = setTimeout(function() {
					previewApi.abort();
					previewApi.post({
						action: 'parse',
						summary: summary,
						prop: '',
						formatversion: '2'
					}).then(function(res) {
						return res && res.parse && typeof res.parse.parsedsummary === 'string' ? res.parse.parsedsummary : null;
					}).catch(/** @param {object} err */ function(_, err) {
						if (err && err.exception !== 'abort') {
							console.log(err);
						}
						return null;
					}).then(/** @param {string?} parsedsummary */ function(parsedsummary) {
						parsedsummary = parsedsummary !== null ? parsedsummary : '???';
						_this.$summaryPreview.prop('innerHTML', parsedsummary);
						_this.$summaryPreviewTooltip.toggle(containsMagicWords); // Toggle the visibility of the magic word tooltip
					});
				}, 500);
			};

			/**
			 * Perform AJAX rollback on a rollback link.
			 * @param {HTMLSpanElement} rbspan The wrapper span of the rollback link.
			 * @param {SRBox?} box The SR checkbox object. (**Note: this method removes the box unconditionally.**)
			 * @param {RollbackParams} [params] Parameters to `action=rollback`. If none is passed, obtained from the dialog.
			 * @returns {JQueryPromise<boolean>} Whether the rollback succeeded.
			 */
			SR.prototype.ajaxRollback = function(rbspan, box, params) {

				var _this = this;
				if (box) box.$wrapper.remove();
				params = params || this.getParams();

				// Collect required parameters to action=rollback from the rollback link internal to the rbspan
				var rblink = rbspan.querySelector('a');
				var href = rblink && rblink.href;
				var title = href && mw.util.getParamValue('title', href);
				var user = href && mw.util.getParamValue('from', href);
				if (!rblink || !title || !user) {
					var info =
						!rblink ? '[SR] Error: Anchor tag is missing in the rollback link for some reason.' :
						!title ? '[SR] Error: The rollback link does not have a "title" query parameter.' :
						'[SR] Error: The rollback link does not have a "from" query parameter.';
					var code = !rblink ? 'linkmissing' : !title ? 'titlemissing' : 'usermissing';
					console.error(info, rbspan);
					this.processRollbackLink(rbspan, code);
					return $.Deferred().resolve(false);
				}

				// Perform AJAX rollback
				this.processRollbackLink(rbspan, null);
				return rollback(title, user, params).then(function(err) {
					_this.processRollbackLink(rbspan, err);
					return !err;
				});

			};

			/**
			 * Replace the innerHTML of a rollback link with the result of a rollback or a loading spinner.
			 *
			 * This method also unbinds the rollback link from the class if needed.
			 * @param {HTMLSpanElement} rbspan
			 * @param {string?} [result] An error code on failure, `null` for a loading spinner.
			 * @returns {void}
			 */
			SR.prototype.processRollbackLink = function(rbspan, result) {
				var $rbspan = $(rbspan);
				if (result === null) {
					// Replace the innerHTML of the rbspan with a loading spinner
					$rbspan
						.prop({'innerHTML': ''})
						.append(
							$('<img>')
								.prop({src: 'https://upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif'})
								.css({
									verticalAlign: 'middle',
									height: '1em',
									border: 0
								})
						);
				} else {

					// Replace the innerHTML of the rbspan with a rollback result
					$rbspan
						.prop({'innerHTML': ''})
						.append(
							document.createTextNode('['),
							$('<span>')
								.text(result ? msg['rbstatus-failed'] + ' (' + result + ')' : msg['rbstatus-reverted'])
								.css({backgroundColor: result ? 'lightpink' : 'lightgreen'}),
							document.createTextNode(']')
						)
						.removeClass('mw-rollback-link')
						.addClass('sr-rollback-link-resolved');

					// Unbind the rbspan from the class
					var /** @type {string} */ index = $rbspan.data('sr-index');
					delete this.links[index];

					// If no rbspan is bound to the class any longer, remove the dialog and the portlet link
					if ($.isEmptyObject(this.links)) {
						this.$dialog.empty().dialog('destroy');
						if (this.portlet) this.portlet.remove();
					}

				}
			};

			/**
			 * Send an `action=rollback` HTTP request.
			 * @param {string} title
			 * @param {string} user
			 * @param {RollbackParams} params
			 * @returns {JQueryPromise<string?>}
			 */
			function rollback(title, user, params) {
				return api.rollback(title, user, params)
					.then(function() {
						return null;
					}).catch(function(code, err) {
						console.log(err);
						return code;
					});
			}

			/**
			 * Perform selective rollback.
			 * @returns {void}
			 */
			SR.prototype.selectiveRollback = function() {

				// Close the dialog and get request parameters
				var params = this.close().getParams();

				// Perform AJAX rollback on links whose associated SR checkboxes are checked
				var /** @type {JQueryPromise<boolean>[]} */ deferreds = [];
				for (var key in this.links) {
					var obj = this.links[key];
					if (obj.box && obj.box.$checkbox.is(':checked')) {
						deferreds.push(this.ajaxRollback(obj.rbspan, obj.box, params));
					}
				}

				// Post-procedures
				if (!deferreds.length) {
					// Show a message if no SR checkbox is checked
					mw.notify(msg['msg-nonechecked'], {type: 'warn'});
				} else {
					// When all rollback requests are done, show a message that tells how many rollback links were processed
					$.when.apply($, deferreds).then(function() {
						var reverted = 0;
						var failed = 0;
						Object.keys(arguments).forEach(function(key) {
							var success = arguments[key];
							if (success) {
								reverted++;
							} else {
								failed++;
							}
						});
						mw.notify(
							$('<div>').append(
								document.createTextNode('Selective Rollback (' + (reverted + failed) + ')'),
								$('<ul>').append(
									$('<li>').text(msg['rbstatus-notify-success'] + ': ' + reverted),
									$('<li>').text(msg['rbstatus-notify-failure'] + ': ' + failed)
								)
							),
							{type: 'success'}
						);
					});
				}

			};

			return SR;

		})();
	}

})();
//</nowiki>