Wikipedysta:Msz2001/MoveToSandbox.js: Różnice pomiędzy wersjami

Z Wikipedii, wolnej encyklopedii
Usunięta treść Dodana treść
usuwam window.messageInput, to chyba mial byc test
Cache'owanie uprawnień, autora i zawartości formularza
Linia 24: Linia 24:
* null title means the default one, but not computed yet
* null title means the default one, but not computed yet
*
*
* @typedef {{targetPage: string, editSummary: string, message: string, defaultMessage: string}} FormResult
* @typedef {{targetPage: string, editSummary: string, message: string, defaultMessage: string, isMessageDisabled: boolean}} FormResult
* @typedef {{_valid: boolean, _errors: string[], targetPage: boolean, editSummary: boolean, message: boolean}} FormValidation
* @typedef {{_valid: boolean, _errors: string[], targetPage: boolean, editSummary: boolean, message: boolean}} FormValidation
* @typedef {{pageId: number, author: {name: string, isAnon: boolean}}} PageMetadata
* @typedef {{pageId: number, author: {name: string, isAnon: boolean}}} PageMetadata
Linia 172: Linia 172:
var moveRight = null;
var moveRight = null;
var suppressRedirectRight = null;
var suppressRedirectRight = null;
var cachedState = null;


/**
/**
Linia 361: Linia 362:


var view = createDialogView();
var view = createDialogView();
if(cachedState) view.setValues(cachedState);


// Populate the dialog with controls
// Populate the dialog with controls
Linia 390: Linia 392:
sourcePageAuthor
sourcePageAuthor
).next(function () {
).next(function () {
targetPage = responses.targetPage;
cachedState = undefined;
deferred.resolve();
deferred.resolve();
dialog.close({ action: action });
dialog.close({ action: action });
});
});
} else if(action == 'cancel') {
} else if(action == 'cancel') {
cachedState = view.getValues();
deferred.reject();
deferred.reject();
dialog.close({ action: action });
dialog.close({ action: action });
Linia 421: Linia 426:
onRightsLoadedHandlers.push(function () {
onRightsLoadedHandlers.push(function () {
if(!moveRight) {
if(!moveRight) {
alert(MSG.errorNoMoveRight);
OO.ui.alert(MSG.errorNoMoveRight);
sandboxDialog.close();
sandboxDialog.close();
deferred.reject();
deferred.reject();
Linia 432: Linia 437:
/**
/**
* Creates the dialog view
* Creates the dialog view
* @returns {{panel: OO.ui.PanelLayout, getValues: () => FormResult, setValidity: (v: FormValidation) => void}}
* @returns {{panel: OO.ui.PanelLayout, getValues: () => FormResult, setValues: (v: FormResult) => void, setValidity: (v: FormValidation) => void}}
*/
*/
function createDialogView() {
function createDialogView() {
Linia 449: Linia 454:
// The "Author" field
// The "Author" field
var authorLabel = new OO.ui.LabelWidget({
var authorLabel = new OO.ui.LabelWidget({
label: MSG.loading
label: sourcePageAuthor
? $(MSG.authorFieldText.replace(/\$1/g, sourcePageAuthor))
: MSG.loading
});
});
var authorField = new OO.ui.FieldLayout(authorLabel, {
var authorField = new OO.ui.FieldLayout(authorLabel, {
Linia 552: Linia 559:
editSummary: summaryInput.getValue(),
editSummary: summaryInput.getValue(),
message: messageInput.getValue(),
message: messageInput.getValue(),
defaultMessage: defaultMessage
defaultMessage: defaultMessage,
isMessageDisabled: messageInput.disabled
};
};
},
setValues: function (values) {
targetInput.setValue(values.targetPage);
summaryInput.setValue(values.editSummary);
messageInput.setValue(values.message);
messageInput.setDisabled(values.isMessageDisabled);
},
},
setValidity: function (validationResult) {
setValidity: function (validationResult) {
Linia 773: Linia 787:


//! Interactions with API
//! Interactions with API
/**
* So that the metadata is fetched only once
* @type {{[pageName: string]: jQuery.Promise}}
*/
var metadataPromises = {};
/**
/**
* Retrieves the current page metadata
* Retrieves the current page metadata
Linia 779: Linia 798:
*/
*/
function getArticleMetadata(title) {
function getArticleMetadata(title) {
if(metadataPromises[title]) return metadataPromises[title];

var deferred = $.Deferred();
var deferred = $.Deferred();


Linia 807: Linia 828:
// catch is going to execute only if there are some missing fields in the response
// catch is going to execute only if there are some missing fields in the response
console.error(e);
console.error(e);
metadataPromises[title] = undefined; // Don't cache failed requests
deferred.reject(MSG.apiFormatError);
deferred.reject(MSG.apiFormatError);
}
}
}).fail(function (reason) {
}).fail(function (reason) {
metadataPromises[title] = undefined; // Don't cache failed requests
deferred.reject(reason);
deferred.reject(reason);
});
});


return deferred.promise();
var promise = deferred.promise();
metadataPromises[title] = promise;
return promise;
}
}


/** So that the rights are fetched only once */
var rightsPromise = null;
/**
/**
* Fetches the rights of the current user
* Fetches the rights of the current user
Linia 821: Linia 848:
*/
*/
function getCurrentUserRights() {
function getCurrentUserRights() {
if(rightsPromise) return rightsPromise;

var deferred = $.Deferred();
var deferred = $.Deferred();


Linia 837: Linia 866:
// catch is going to execute only if there are some missing fields in the response
// catch is going to execute only if there are some missing fields in the response
console.error(e);
console.error(e);
rightsPromise = null; // Don't cache failed requests
deferred.reject(MSG.apiFormatError);
deferred.reject(MSG.apiFormatError);
}
}
}).fail(function (reason) {
}).fail(function (reason) {
rightsPromise = null; // Don't cache failed requests
deferred.reject(reason);
deferred.reject(reason);
});
});


return deferred.promise();
rightsPromise = deferred.promise();
return rightsPromise;
}
}



Wersja z 12:04, 8 cze 2022

/* global mw, jQuery, moveToSandboxGadget, OO */
/**
 * @author [[w:pl:User:Beau]] (original code)
 * @author [[w:pl:User:Wargo]]
 * @author [[w:pl:User:Nux]]
 * @author [[w:pl:User:Msz2001]] (rewrite for OOUI)
 * 
 * Full list of authors:
 *   https://pl.wikipedia.org/w/index.php?title=MediaWiki:Gadget-move-to-sandbox.js&action=history
 * 
 * <nowiki>
 * 
 ** Interface:
 *  window.moveToSandboxGadget = {
 *      moveSource: string | null,
 *      moveDestination: string | null,
 *      open: (callback: (success: bool) => void, discussion: string) => void,
 *      version: string
 *  };
 * 
 ** Important: If setting both moveSource and moveDestination, moveSource must be set before moveDestination.
 **     Otherwise, the moveDestination will be overwritten by the default target title.
 * 
 * null title means the default one, but not computed yet
 * 
 * @typedef {{targetPage: string, editSummary: string, message: string, defaultMessage: string, isMessageDisabled: boolean}} FormResult
 * @typedef {{_valid: boolean, _errors: string[], targetPage: boolean, editSummary: boolean, message: boolean}} FormValidation
 * @typedef {{pageId: number, author: {name: string, isAnon: boolean}}} PageMetadata
 */
(function ($, mw) {
	var GADGET_VERSION = '9.0';

	// An edit tag to use ([[Special:Tags]])
	var EDIT_TAG = undefined;

	//! Translatable content
	var MSG = {
		toolboxLink: 'Przenieś do brudnopisu',
		toolboxLinkTooltip: 'Przenieś tę stronę do brudnopisu autora',
		dialogTitle: 'Przenieś do brudnopisu',
		dialogCancel: 'Anuluj',
		dialogSubmit: 'Przenieś',
		loading: 'Ładowanie...',

		speedyTemplate: '{{ek|1=strona została przeniesiona do [[$1|brudnopisu autora]]}}\n',
		speedySummary: 'Oznaczono przekierowanie po przenosinach jako do [[:Kategoria:Ekspresowe kasowanie|skasowania]]',
		removeCategoriesSummary: 'Usunięto kategorie z brudnopisu.',
		removeTemplatesSummary: 'Usunięto szablony z brudnopisu: $1.',
		talkMessageSubject: '[[$1|$2]]',   // $1 = permanent link to the page (Special:Redirect), $2 = original page title

		guestSandbox: 'Wikipedia:Brudnopis gościnny/$1',
		userMessageBody: 'Witaj. Twój artykuł nie nadaje się jeszcze do publikacji w Wikipedii, dlatego został przeniesiony do twojego \'\'\'[[$link|brudnopisu]]\'\'\', gdzie możesz nad nim popracować. Popraw w nim:\n* ...\n* ...\n* ...\n* ...\n\nW razie problemów skorzystaj z [[Pomoc:Jak napisać nowy artykuł|tego poradnika]], zadaj pytanie na [[Pomoc:Pytania nowicjuszy|tej stronie]] lub spytaj [[Pomoc:Przewodnicy|przewodników]].\n\nPo skończeniu użyj [[Pomoc:Zmiana nazwy strony|zakładki „Przenieś”]], aby ponownie opublikować artykuł. Jeżeli nie masz takiej zakładki (należy mieć konto zarejestrowane od co najmniej 4 dni oraz 10 edycji), napisz na [[Pomoc:Pytania nowicjuszy|tej stronie]] lub zwróć się do [[Pomoc:Przewodnicy|przewodników]].',
		userMessageAnonFooter: '\n\nZapisz sobie podany adres strony, aby nie mieć problemu z powrotem do niej: \'\'\'$link\'\'\'',
		userMessageSignature: '\n\nPozdrawiam, ~~~~',

		authorFieldLabel: 'Autor artykułu',
		authorFieldUnknown: 'nieznany',
		authorCannotFetch: '<span style="color:#a00">Nie udało się załadować autora. $1</span>',
		authorFieldText: '<span><a class="mw-userlink" href="/wiki/User:$1" target="_blank">$1</a> (<a href="/wiki/User_talk:$1" target="_blank">dyskusja</a>,&nbsp;<a href="/wiki/Special:Contributions/$1" target="_blank">wkład</a>)</span>',
		targetFieldLabel: 'Docelowa nazwa artykułu',
		targetFieldPlaceholder: 'np. Wikipedysta:Ktoś/brudnopis',
		summaryFieldLabel: 'Krótkie uzasadnienie',
		summaryFieldPlaceholder: 'Ten tekst zostanie użyty jako opis zmian',
		summaryFieldDefault: 'Artykuł należy dopracować',
		summaryFieldHelp: 'Zawartość tego pola będzie widoczna jako opis edycji w rejestrze przeniesień i historii strony.',
		messageFieldLabel: 'Wiadomość dla użytkownika',
		messageFieldPlaceholder: 'Tu wpisz komunikat, który zostanie umieszczony na stronie dyskusji użytkownika',
		messageFieldHelp: 'Możesz używać znaczników wikikodu. Tekst „$link” zostanie zamieniony na nazwę strony, gdzie przeniesiono artykuł.',

		errorNoMoveRight: 'Nie masz uprawnień do przenoszenia stron.',
		errorFormInvalid: 'Formularz nie został wypełniony poprawnie: <ul>$1</ul>',
		errorTargetEmpty: 'Nazwa strony docelowej nie może być pusta.',
		errorSummaryEmpty: 'Uzasadnienie nie może być puste.',
		errorMessageDefault: 'Uzupełnij wiadomość dla użytkownika, wskazując, co należy poprawić.',

		// $1 is the actual error message
		errorMoveFailed: 'Nie udało się przenieść strony do brudnopisu. $1',
		errorMarkToDeleteFailed: 'Strona została przeniesiona, ale nie udało się oznaczyć przekierowania jako do skasowania. $1',
		errorRemoveCategoriesFailed: 'Strona została przeniesiona, ale nie udało się usunąć kategorii z brudnopisu. $1',
		errorTalkMessageFailed: 'Strona została przeniesiona, ale nie udało się wysłać wiadomości do użytkownika. $1',

		apiFail: 'Żądanie API zakończyło się błędem:\n$1',
		apiAbort: 'Połączenie zostało przerwane.',
		apiTimeout: 'Czas oczekiwania na odpowiedź serwera upłynął.',
		apiNetworkError: 'Wystąpił błąd sieci. Upewnij się, że masz połączenie z Internetem.',
		apiHttpError: 'Serwer zwrócił błąd $1.',
		apiFormatError: 'Serwer odpowiedział w nieprawidłowym formacie. Szczegóły błędu być może znajdują się w konsoli w narzędziach deweloperskich (zazwyczaj dostępnych po naciśnięciu F12).',
		apiPageDeleted: 'Strona została usunięta.',
		apiGeneralError: 'Serwer nie był w stanie przetworzyć żądania: $1'
	};

	// These will be removed from the page content after moving to sandbox
	var TEMPLATES_TO_REMOVE = ['EK', 'DNU'];

	// The common reasons for moving a page to a sandbox
	var QUICK_REASONS = [
		{
			label: 'Przypisy',
			tooltip: 'Brakuje przypisów',
			insertedMessage: 'Dodaj [[Wikipedia:Weryfikowalność|źródła]] w formie [[Pomoc:Przypisy|przypisów]] do wszystkich informacji.'
		},
		{
			label: 'Formatowanie',
			tooltip: 'Formatowanie wymaga poprawy',
			insertedMessage: 'Popraw [[Pomoc:Formatowanie tekstu|formatowanie tekstu]].'
		},
		{
			label: 'Kategorie',
			tooltip: 'Brakuje kategorii',
			insertedMessage: 'Dodaj [[Pomoc:Kategorie|kategorie]].'
		},
		{
			label: 'Linki',
			tooltip: 'Za mało linków wewnętrznych',
			insertedMessage: 'Dodaj [[Wikipedia:Linki|linki do innych artykułów]].'
		},
		{
			label: 'ENCY',
			tooltip: 'Nie wykazano encyklopedyczności',
			insertedMessage: 'Wykaż [[WP:ENCY|encyklopedyczność]] tematu.'
		},
		{
			label: 'Tłumacz',
			tooltip: 'Użyto autotranslatora',
			insertedMessage: 'Popraw tłumaczenie maszynowe.'
		},
		{
			label: 'Rozbuduj',
			tooltip: 'Artykuł jest za krótki',
			insertedMessage: '[[Pomoc:Jak napisać nowy artykuł|Rozbuduj]] artykuł.'
		},
		{
			label: 'Styl',
			tooltip: 'Należy poprawić styl tekstu',
			insertedMessage: '[[Pomoc:Styl – poradnik dla autorów|Popraw styl]] artykułu, tak żeby pasował do encyklopedii.'
		},
		{
			label: 'NPOV',
			tooltip: 'Artykuł nie jest napisany w sposób neutralny',
			insertedMessage: 'Opisz zagadnienie w sposób [[WP:NPOV|neutralny]].'
		}
	];
	/**
	 * If there are any additional messages specified, they will go here
	 * This is a map so that no message will be duplicated
	 * @type {{[key: string]: {label: string, tooltip: string, insertedMessage: string}}}
	 */
	var additionalReasons = {};

	/**
	 * Makes the target title, based on the author's name and the current page title
	 * @param {string | null} author The author's name. Null when the autor cannot be determined
	 * @param {bool} isAuthorAnon Whether the author is anonymous
	 * @param {string} title The current page title
	 * @returns {string} The target title
	 */
	function makeTargetTitle(author, isAuthorAnon, originalTitle) {
		// Use the guests' sandbox if the author is anonymous or unknown
		if(!author || isAuthorAnon) return MSG.guestSandbox.replace(/\$1/g, originalTitle);

		return 'Wikipedysta:' + author + '/' + originalTitle;
	}

	//! Start of the proper gadget code

	var sourcePage = null;
	var targetPage = null;
	var sourcePageAuthor = '';
	var sourcePageAuthorIsAnon = false;
	var sourcePageId = null;
	var api = null;
	var moveRight = null;
	var suppressRedirectRight = null;
	var cachedState = null;

	/**
	 * Array of functions that get called when the author is fetched.
	 * If there is any error, the message is passed as an argument.
	 * @type {((error: string | undefined) => void)[]}
	 */
	var onAuthorLoadedHandlers = [];

	/**
	 * Array of functions that get called when the author's rights are fetched.
	 * @type {(() => void)[]}
	 */
	var onRightsLoadedHandlers = [];

	/** Installs the gadget (entry point) */
	function install() {
		// The interface is available everywhere (i.e. for the delReqHandler gadget)
		buildPublicInterface();

		// It's intended to be invoked manually only on article pages
		if(mw.config.get('wgNamespaceNumber') !== 0) return;

		// Create a toolbox link
		var portletId = mw.config.get('skin') === 'timeless' ? 'p-pagemisc' : 'p-tb';
		var link = mw.util.addPortletLink(portletId, '#', MSG.toolboxLink, 'move-to-sandbox', MSG.toolboxLinkTooltip);
		// bind toolbox link
		$(link).on('click', function (e) {
			e.preventDefault();
			loadDependenciesAndDisplay().then(function () {
				// The page is reloaded only if the dialog was invoked directly
				// (i.e. not from the delReqHandler gadget)
				location.reload();
			});
		});
	}

	/** Builds and exposes an interface that may be used by other gadgets */
	function buildPublicInterface() {
		window.moveToSandboxGadget = {
			version: GADGET_VERSION,
			open: function (callback, discussion) {
				// For safety
				callback = callback || function () {};

				if(discussion !== undefined){
					// Mimick the legacy behavior
					additionalReasons['delReqHandler-discussion'] = {
						label: 'Dyskusja',
						tooltip: 'Link do dyskusji w DNU',
						insertedMessage: discussion
					};
				}else{
					additionalReasons['delReqHandler-discussion'] = undefined;
				}

				loadDependenciesAndDisplay()
					.then(function () { callback(true); })
					.fail(function () { callback(false); });
			}
		};

		// Changing the source title will invalidate any target title set before.
		// This is done in order to ensure that the target title is always up-to-date.
		Object.defineProperty(window.moveToSandboxGadget, 'moveSource', {
			get: function () { return sourcePage; },
			set: function (value) { sourcePage = value; targetPage = null; }
		});
		Object.defineProperty(window.moveToSandboxGadget, 'moveDestination', {
			get: function () { return targetPage; },
			set: function (value) { targetPage = value; }
		});
	}

	/**
	 * Ensures that all the dependencies are present and displays the dialog
	 * @returns {jQuery.Promise<void>} A promise that's resolved when the move process finishes successfully. It's rejected when the user quits the dialog.
	 */
	function loadDependenciesAndDisplay() {
		var deferred = $.Deferred();

		mw.loader.using(
			['oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows', 'mediawiki.api'],
			function () {
				// Only now we are certain that API exists
				createApiClient();

				// Ensure that both the source and destination are set.
				// But do not block the dialog from displaying, since
				// the data is in fact needed after users fills in the form.
				computeAuthorAndPageTitles();

				onRightsLoadedHandlers = [];
				getCurrentUserRights().then(function (rights) {
					moveRight = rights.indexOf('move') !== -1;
					suppressRedirectRight = rights.indexOf('suppressredirect') !== -1;
					onRightsLoadedHandlers.forEach(function (handler) { handler(); });

					if(!moveRight) {
						deferred.reject();
					}
				});

				displaySandboxDialog()
					.then(deferred.resolve)
					.fail(deferred.reject);
			}
		);

		return deferred.promise();
	}

	/** Creates the API client and sets some default options */
	function createApiClient() {
		api = new mw.Api({
			parameters: {
				format: 'json',
				formatversion: 2,
				errorformat: 'html',
				errorlang: mw.config.get('wgUserLanguage'),
				errorsuselocal: true,
				tags: EDIT_TAG
			}
		});
	}

	/**
	 * Initiate loading of the author's name and then prepare titles of source and
	 * target pages (if not already set).
	 */
	function computeAuthorAndPageTitles() {
		// Ensure that there are no leftover handlers
		onAuthorLoadedHandlers = [];

		// If there's no custom source, use the current page
		if(!sourcePage) sourcePage = mw.config.get('wgPageName').replace(/_/g, ' ');

		// Get metadata and compute target title
		getArticleMetadata(sourcePage).done(function (metadata) {
			var author = metadata.author;
			sourcePageAuthor = author.name;
			sourcePageAuthorIsAnon = author.isAnon;
			sourcePageId = metadata.pageId;

			if(!targetPage) targetPage = makeTargetTitle(author.name, author.isAnon, sourcePage);

			onAuthorLoadedHandlers.forEach(function (handler) { handler(undefined); });
		}).fail(function (reason) {
			sourcePageAuthor = null;
			sourcePageAuthorIsAnon = true;
			sourcePageId = null;

			// Prepare target title as if the user was unknown
			if(!targetPage) targetPage = makeTargetTitle(null, true, sourcePage);

			reason = MSG.authorCannotFetch.replace(/\$1/g, reason);
			onAuthorLoadedHandlers.forEach(function (handler) { handler(reason); });
		});
	}

	//! Start of the dialog window definition
	/**
	 * Displays the dialog
	 * @returns {jQuery.Promise<void>} A promise that is resolved when the page is moved. Fails if the user quits the dialog.
	 */
	function displaySandboxDialog() {
		var deferred = $.Deferred();

		// Initialize the dialog's class
		function MoveToSandboxDialog(config) {
			MoveToSandboxDialog.super.call(this, config);
		}
		OO.inheritClass(MoveToSandboxDialog, OO.ui.ProcessDialog);

		MoveToSandboxDialog.static.name = 'MoveToSandboxDialog';
		MoveToSandboxDialog.static.title = MSG.dialogTitle;
		MoveToSandboxDialog.static.actions = [
			{
				action: 'cancel',
				label: MSG.dialogCancel,
				flags: 'safe',
			},
			{
				action: 'submit',
				label: MSG.dialogSubmit,
				flags: ['primary', 'progressive']
			}
		];

		var view = createDialogView();
		if(cachedState) view.setValues(cachedState);

		// Populate the dialog with controls
		MoveToSandboxDialog.prototype.initialize = function () {
			MoveToSandboxDialog.super.prototype.initialize.call(this);

			// Create layout
			this.$body.append(view.panel.$element);
		};

		MoveToSandboxDialog.prototype.getActionProcess = function (action) {
			var dialog = this;
			if(action == 'submit') {
				var responses = view.getValues();
				normalizeInput(responses);
				var validity = validateInput(responses);
				view.setValidity(validity);
				dialog.updateSize();

				// If the input is invalid, return a process that does nothing.
				if(!validity._valid) {
					return new OO.ui.Process(function () { });
				}
				return moveToSandbox(
					sourcePage,
					responses.targetPage,
					responses.editSummary,
					responses.message,
					sourcePageAuthor
				).next(function () {
					targetPage = responses.targetPage;
					cachedState = undefined;
					deferred.resolve();
					dialog.close({ action: action });
				});
			} else if(action == 'cancel') {
				cachedState = view.getValues();
				deferred.reject();
				dialog.close({ action: action });
			}
			return MoveToSandboxDialog.super.prototype.getActionProcess.call(this, action);
		};

		// Make the window
		var sandboxDialog = new MoveToSandboxDialog({
			size: 'large',
		});

		// Create and append a window manager
		var windowManager = new OO.ui.WindowManager();
		$(document.body).append(windowManager.$element);
		windowManager.addWindows([sandboxDialog]);
		windowManager.openWindow(sandboxDialog);

		// In case of very long author name (or IPv6), there may be a vertical overflow, so update the size
		onAuthorLoadedHandlers.push(function () {
			try {
				sandboxDialog.updateSize();
			} catch(e) {
				// If the user closes dialog before fetching author, the exception is thrown here
			}
		});
		onRightsLoadedHandlers.push(function () {
			if(!moveRight) {
				OO.ui.alert(MSG.errorNoMoveRight);
				sandboxDialog.close();
				deferred.reject();
			}
		});

		return deferred.promise();
	}

	/**
	 * Creates the dialog view
	 * @returns {{panel: OO.ui.PanelLayout, getValues: () => FormResult, setValues: (v: FormResult) => void, setValidity: (v: FormValidation) => void}}
	 */
	function createDialogView() {
		var rootPanel = new OO.ui.PanelLayout({
			padded: true,
			expanded: false,
		});

		// Here will be displayed any errors
		var errorContainer = $('<div class="mw-gadget-mts-errorbox"></div>').hide();
		rootPanel.$element.append(errorContainer);

		var fieldset = new OO.ui.FieldsetLayout({});
		rootPanel.$element.append(fieldset.$element);

		// The "Author" field
		var authorLabel = new OO.ui.LabelWidget({
			label: sourcePageAuthor
				? $(MSG.authorFieldText.replace(/\$1/g, sourcePageAuthor))
				: MSG.loading
		});
		var authorField = new OO.ui.FieldLayout(authorLabel, {
			label: MSG.authorFieldLabel,
			align: 'left'
		});

		// The "Target page" field
		var targetInput = new OO.ui.TextInputWidget({
			value: targetPage || '',  // Will be filled in later
			placeholder: MSG.targetFieldPlaceholder
		});
		var targetField = new OO.ui.FieldLayout(targetInput, {
			label: MSG.targetFieldLabel,
			align: 'left'
		});

		// The "Edit summary" field
		var summaryInput = new OO.ui.TextInputWidget({
			placeholder: MSG.summaryFieldPlaceholder,
			value: MSG.summaryFieldDefault
		});
		var summaryField = new OO.ui.FieldLayout(summaryInput, {
			label: MSG.summaryFieldLabel,
			help: MSG.summaryFieldHelp,
			align: 'left'
		});

		// The "Message" field
		var defaultMessage = MSG.userMessageBody + MSG.userMessageSignature;
		var messageInput = new OO.ui.MultilineTextInputWidget({
			placeholder: MSG.messageFieldPlaceholder,
			value: defaultMessage,
			multiline: true,
			rows: 12
		});
		var messageField = new OO.ui.FieldLayout(messageInput, {
			label: MSG.messageFieldLabel,
			helpInline: true,
			help: MSG.messageFieldHelp,
			align: 'top'
		});

		onAuthorLoadedHandlers.push(function (error) {
			authorLabel.setLabel(
				sourcePageAuthor
					? $(MSG.authorFieldText.replace(/\$1/g, sourcePageAuthor))
					: ($(error) || MSG.authorFieldUnknown)
			);
			if(targetInput.getValue() === '') targetInput.setValue(targetPage);

			if(sourcePageAuthorIsAnon && messageInput.getValue() == defaultMessage) {
				defaultMessage = MSG.userMessageBody + MSG.userMessageAnonFooter + MSG.userMessageSignature;
				messageInput.setValue(defaultMessage);
			}
			messageInput.setDisabled(error !== undefined);
		});

		// Display buttons for quick insertion of common messages
		var quickReasonsWrapper = $('<div></div>');
		rootPanel.$element.append(quickReasonsWrapper);

		var reasons = QUICK_REASONS.concat(Object.values(additionalReasons));
		reasons.forEach(function (reason) {
			if(!reason) return;
			
			var btn = new OO.ui.ButtonWidget({
				label: reason.label,
				title: reason.tooltip,
				flags: 'progressive',
				framed: false
			});

			btn.on('click', function () {
				if(messageInput.disabled) return;

				// Obtains the current selection and sets it back.
				// Without the following two lines, the textbox is scrolled to the end when inserting content.
				// Very hacky, but it works. ¯\_(ツ)_/¯
				var range = messageInput.getRange();
				messageInput.selectRange(range.from, range.to);

				messageInput.insertContent(reason.insertedMessage);
			});

			quickReasonsWrapper.append(btn.$element);
		});

		// Insert the fields into the fieldset
		fieldset.addItems([
			authorField,
			targetField,
			summaryField,
			messageField
		]);

		return {
			panel: rootPanel,
			getValues: function () {
				return {
					targetPage: targetInput.getValue(),
					editSummary: summaryInput.getValue(),
					message: messageInput.getValue(),
					defaultMessage: defaultMessage,
					isMessageDisabled: messageInput.disabled
				};
			},
			setValues: function (values) {
				targetInput.setValue(values.targetPage);
				summaryInput.setValue(values.editSummary);
				messageInput.setValue(values.message);
				messageInput.setDisabled(values.isMessageDisabled);
			},
			setValidity: function (validationResult) {
				targetInput.setValidityFlag(validationResult.targetPage);
				summaryInput.setValidityFlag(validationResult.editSummary);
				messageInput.setValidityFlag(validationResult.message);

				if(!validationResult._valid) {
					var errorsList = validationResult._errors.map(function (x) { return '<li>' + x + '</li>'; });
					errorContainer.html(
						MSG.errorFormInvalid.replace(/\$1/g, errorsList.join(''))
					).show();
				} else {
					errorContainer.html('').hide();
				}
			}
		};
	}

	//! Prepare the input for the actual move
	/**
	 * Removes empty bullet points from the message
	 * @param {string} message The string to manipulate
	 * @returns {string} The same string but without empty bullet points (i.e. "* ...")
	 */
	function removeEmptyBulletPoints(message) {
		return message.replace(/^\* \.\.\.\n/gm, '');
	}

	/**
	 * Normalizes the data, i.e. removes leading and trailing whitespace
	 * @param {FormResult} data The data retrieved from the form
	 */
	function normalizeInput(data) {
		data.targetPage = data.targetPage.trim();
		data.editSummary = data.editSummary.trim();
		data.message = removeEmptyBulletPoints(data.message.trim());
	}

	/**
	 * Checks whether all the fields were filled in correctly
	 * @param {FormResult} data The data retrieved from the form
	 * @returns {FormValidation} Whether the specific fields are valid
	 */
	function validateInput(data) {
		var result = {
			_valid: true,
			_errors: [],
			targetPage: true,
			editSummary: true,
			message: true
		};

		// Target page cannot be empty
		if(!data.targetPage) {
			result.targetPage = false;
			result._errors.push(MSG.errorTargetEmpty);
		}

		// Edit summary cannot be empty
		if(!data.editSummary) {
			result.editSummary = false;
			result._errors.push(MSG.errorSummaryEmpty);
		}

		// Message can be empty but the user should edit the default message
		if(data.message == removeEmptyBulletPoints(data.defaultMessage)) {
			result.message = false;
			result._errors.push(MSG.errorMessageDefault);
		}

		// Check if there are any problems
		Object.values(result).forEach(function (value) {
			if(value === false) result._valid = false;
		});

		return result;
	}

	//! Do the actual move
	/**
	 * Does all the steps in process of moving to sandbox
	 * @param {string} sourceTitle The title of the page to move
	 * @param {string} targetTitle The title of the page to move to
	 * @param {string} summary The edit summary
	 * @param {string} message The message to post on the talk page
	 * @param {string} author User who should be messaged
	 * @returns {OO.ui.Process} The process representing the steps
	 */
	function moveToSandbox(sourceTitle, targetTitle, summary, message, author) {
		var leaveRedirect = !suppressRedirectRight;

		// We use the page ID instead of the title to make sure that user
		// can navigate to the page no matter how many times it was moved.
		var permalink = 'Special:Redirect/page/' + sourcePageId;
		if(!sourcePageId) {
			permalink = targetTitle;
		}
		
		var messageSubject = MSG.talkMessageSubject.replace(/\$2/g, sourceTitle);
		messageSubject = messageSubject.replace(/\$1/g, permalink);

		// Use target title directly, because the text calls it "sandbox" (not to confuse user after fixing the article)
		message = message.replace(/\$link/g, targetTitle);

		return new OO.ui.Process()
			.next(function () {
				// Move the page
				return errorIfRejected(
					movePage(sourceTitle, targetTitle, summary, leaveRedirect),
					MSG.errorMoveFailed
				);
			})
			.next(function () {
				if(!leaveRedirect) return true;
				// Request deletion of the source redirect
				return errorIfRejected(
					prependText(sourceTitle, MSG.speedyTemplate.replace(/\$1/g, targetTitle), MSG.speedySummary),
					MSG.errorMarkToDeleteFailed,
					{ warning: true }
				);
			})
			.next(function () {
				// Remove categories and some templates
				return errorIfRejected(
					removeCategoriesAndTemplates(targetTitle, TEMPLATES_TO_REMOVE, MSG.removeCategoriesSummary, MSG.removeTemplatesSummary),
					MSG.errorRemoveCategoriesFailed,
					{ warning: true }
				);
			})
			.next(function () {
				// Don't send the message if the author is unknown
				if(sourcePageAuthor === null) return true;
				if(message === '') return true;

				// Send message to the author
				return errorIfRejected(
					postToUserTalk(author, messageSubject, message),
					MSG.errorTalkMessageFailed,
					{ warning: true }
				);
			});
	}

	/**
	 * Removes all categories and specified templates from the page
	 * @param {string} pageTitle The title of the edited page
	 * @param {string[]} templates The templates to remove
	 * @param {string} categoriesSummary The edit summary used when removing categories
	 * @param {string} templatesSummary The edit summary used when removing templates
	 * @returns {jQuery.Promise<void>} A promise that resolves when the process is finished
	 */
	function removeCategoriesAndTemplates(pageTitle, templates, categoriesSummary, templatesSummary) {
		var deferred = $.Deferred();

		getPageContent(pageTitle).then(function (wikitext) {
			var wikitextChanged = false;
			var editSummaryParts = [];
			var removedTemplates = [];

			// Remove categories
			if(wikitext.search(/\[\[kategoria:/i) >= 0) {
				wikitextChanged = true;
				wikitext = wikitext.replace(/\[\[kategoria:/gi, '[[:Kategoria:');
				editSummaryParts.push(categoriesSummary);
			}

			// Remove templates
			for(var i = 0; i < templates.length; i++) {
				var template = templates[i];
				var regex = new RegExp('\\{\\{' + template + '[^}]*\\}\\}\\s*', 'gi');
				if(wikitext.search(regex) >= 0) {
					wikitextChanged = true;
					wikitext = wikitext.replace(regex, '');
					removedTemplates.push(template);
				}
			}
			if(removedTemplates.length > 0) {
				templatesSummary = templatesSummary.replace(/\$1/g, removedTemplates.join(', '));
				editSummaryParts.push(templatesSummary);
			}

			// If nothing changed, don't do a null edit
			if(!wikitextChanged) return deferred.resolve();

			var editSummary = editSummaryParts.join(' ');
			savePage(pageTitle, wikitext, editSummary).then(function () {
				deferred.resolve();
			}).fail(function (reason) {
				deferred.reject(reason);
			});
		}).fail(function (reason) {
			deferred.reject(reason);
		});

		return deferred.promise();
	}

	/**
	 * Wraps a promise so that if it's rejected, the new promise will be rejected with
	 * OO.ui.Error as a reason.
	 * @param {jQuery.Promise} promise A promise to wrap
	 * @param {string?} messageTemplate A messageTempate. `$1` will be replaced with the original promise's rejection reason
	 * @param {object?} errorOptions Options to pass to OO.ui.Error
	 * @returns {jQuery.Promise}
	 */
	function errorIfRejected(promise, messageTemplate, errorOptions) {
		var deferred = $.Deferred();

		promise.then(function (result) {
			deferred.resolve(result);
		}).fail(function (reason) {
			var message = (messageTemplate || '$1').replace(/\$1/g, reason);
			deferred.reject(new OO.ui.Error(message, errorOptions));
		});

		return deferred.promise();
	}

	//! Interactions with API
	/**
	 * So that the metadata is fetched only once
	 * @type {{[pageName: string]: jQuery.Promise}}
	 */
	var metadataPromises = {};
	/**
	 * Retrieves the current page metadata
	 * @param {string} title The title of the page to check the metadata
	 * @returns {jQuery.Promise<PageMetadata>} A promise that resolves to the metadata
	 */
	function getArticleMetadata(title) {
		if(metadataPromises[title]) return metadataPromises[title];

		var deferred = $.Deferred();

		var params = {
			action: 'query',
			prop: 'revisions',
			titles: title,
			rvprop: 'user',
			rvslots: 'main',
			rvlimit: 1,
			rvdir: 'newer'
		};

		doAPICall(params).done(function (data) {
			try {
				var page = data.query.pages[0];
				var revision = page.revisions[0];
				var authorName = revision.user;
				var isAnon = revision.anon !== undefined;
				deferred.resolve({
					pageId: page.pageid,
					author: {
						name: authorName,
						isAnon: isAnon
					}
				});
			} catch(e) {
				// catch is going to execute only if there are some missing fields in the response
				console.error(e);
				metadataPromises[title] = undefined;   // Don't cache failed requests
				deferred.reject(MSG.apiFormatError);
			}
		}).fail(function (reason) {
			metadataPromises[title] = undefined;   // Don't cache failed requests
			deferred.reject(reason);
		});

		var promise = deferred.promise();
		metadataPromises[title] = promise;
		return promise;
	}

	/** So that the rights are fetched only once */
	var rightsPromise = null;
	/**
	 * Fetches the rights of the current user
	 * @returns {jQuery.Promise<string[]>} A promise that resolves to the list of user rights
	 */
	function getCurrentUserRights() {
		if(rightsPromise) return rightsPromise;

		var deferred = $.Deferred();

		var params = {
			action: 'query',
			meta: 'userinfo',
			uiprop: 'rights'
		};

		doAPICall(params).done(function (data) {
			try {
				var userInfo = data.query.userinfo;
				var rights = userInfo.rights;
				deferred.resolve(rights);
			} catch(e) {
				// catch is going to execute only if there are some missing fields in the response
				console.error(e);
				rightsPromise = null;   // Don't cache failed requests
				deferred.reject(MSG.apiFormatError);
			}
		}).fail(function (reason) {
			rightsPromise = null;   // Don't cache failed requests
			deferred.reject(reason);
		});

		rightsPromise = deferred.promise();
		return rightsPromise;
	}

	/**
	 * Moves a page
	 * @param {string} from The title of the page to move
	 * @param {string} to The new title of the page
	 * @param {string} summary The edit summary
	 * @param {boolean} leaveRedirect Whether to leave a redirect on the old page
	 * @returns {jQuery.Promise<void>} A promise that resolves when the move is done
	 */
	function movePage(from, to, summary, leaveRedirect) {
		var deferred = $.Deferred();
		var params = {
			action: 'move',
			from: from,
			to: to,
			reason: summary,
			movetalk: true,
			noredirect: !leaveRedirect,
			watchlist: 'nochange'
		};

		doAPICall(params).done(function (data) {
			// If the API returns something different than we expected, treat it as an arror
			if(!data.move || !data.move.to) {
				return deferred.reject(MSG.apiFormatError);
			}
			deferred.resolve();
		}).fail(function (reason) {
			deferred.reject(reason);
		});

		return deferred.promise();
	}

	/**
	 * Prepends a text to the page content
	 * @param {string} title The page to edit
	 * @param {string} prependedText The text to prepend
	 * @param {string} summary The edit summary
	 * @returns {jQuery.Promise<void>} A promise that resolves when the page is edited
	 */
	function prependText(title, prependedText, summary) {
		var deferred = $.Deferred();
		var params = {
			action: 'edit',
			title: title,
			summary: summary,
			prependtext: prependedText,
			nocreate: true
		};

		doAPICall(params).then(function (data) {
			// If the API returns something different than we expected, treat it as an arror
			if(!data.edit || !data.edit.newrevid) {
				return deferred.reject(MSG.apiFormatError);
			}
			deferred.resolve();
		}).fail(function (reason) {
			deferred.reject(reason);
		});

		return deferred.promise();
	}

	/**
	 * Fetches the page content
	 * @param {string} title The title of the page to read
	 * @returns {jQuery.Promise<string>} A promise that resolves to the page content
	 */
	function getPageContent(title) {
		var deferred = $.Deferred();
		var params = {
			action: 'query',
			prop: 'revisions',
			titles: title,
			rvprop: 'content',
			rvslots: 'main',
			rvlimit: 1
		};

		doAPICall(params).done(function (data) {
			try {
				var pages = data.query.pages;
				var page = Object.values(pages)[0];
				var revision = page.revisions[0];
				var content = revision.slots.main.content;
				deferred.resolve(content);
			} catch(e) {
				// catch is going to execute only if there are some missing fields in the response
				console.error(e);
				deferred.reject(MSG.apiFormatError);
			}
		}).fail(function (reason) {
			deferred.reject(reason);
		});

		return deferred.promise();
	}

	/**
	 * Saves the page with specified content
	 * @param {string} title The title of the page to edit
	 * @param {string} content The new content of the page
	 * @param {string} summary The edit summary
	 * @returns {jQuery.Promise<void>} A promise that resolves when the edit is successful
	 */
	function savePage(title, content, summary) {
		var deferred = $.Deferred();
		var params = {
			action: 'edit',
			title: title,
			summary: summary,
			text: content,
			nocreate: true
		};

		doAPICall(params).then(function (data) {
			// If the API returns something different than we expected, treat it as an arror
			if(!data.edit || !data.edit.newrevid) {
				return deferred.reject(MSG.apiFormatError);
			}
			deferred.resolve();
		}).fail(function (reason) {
			deferred.reject(reason);
		});

		return deferred.promise();
	}

	/**
	 * Posts a message to the user's talk page
	 * @param {string} user The user to post the message to
	 * @param {string} subject The subject of the message
	 * @param {string} message The message
	 * @returns {jQuery.Promise<void>} A promise that resolves when the message is sent
	 */
	function postToUserTalk(user, subject, message) {
		var deferred = $.Deferred();

		mw.loader.using(['mediawiki.Title', 'mediawiki.messagePoster'], function () {
			var title = new mw.Title('User talk:' + user);
			mw.messagePoster.factory.create(title).then(function (poster) {
				poster.post(subject, message).then(function (data) {
					// Resolve the promise if the post was successful
					// Usually it's going to be a normal edit
					// but it may happen that the talk page is a Flow page - check both
					if(data.edit && data.edit.result === 'Success') return deferred.resolve();
					if(data.flow && data.flow['new-topic'].status == 'ok') return deferred.resolve();

					deferred.reject(MSG.apiFormatError);
				}).fail(function (primaryError, secondaryError, details) {
					console.error(primaryError, secondaryError, details);

					if(primaryError != 'api-fail') return deferred.reject(details);
					var errorInfo = api.getErrorMessage(secondaryError);
					deferred.reject(errorInfo);
				});
			});
		});

		return deferred.promise();
	}

	/**
	 * Does a MediaWiki API request.
	 * 
	 * @param {object} params Query parameters.
	 * @returns {jQuery.Promise<any>} Promise resolving to the API response
	 */
	function doAPICall(params) {
		var deferred = $.Deferred();

		var apiFunc = api.postWithEditToken;
		if(params.action === 'query') apiFunc = api.get;

		// Ahh... Javascript's this... :/
		// .call makes it safe to call apiFunc on its own
		apiFunc.call(api, params).done(function (result, jqXHR) {
			deferred.resolve(result);
		}).fail(function (code, result) {
			var errorMessage = processApiError(code, result);
			return deferred.reject(
				MSG.apiFail.replace(/\$1/g, errorMessage)
			);
		});

		return deferred.promise();
	}

	/**
	 * Processes the data obtained from the API in order to prepare the error message
	 * 
	 * @param {string} code The error code returned by the API
	 * @param {object} result The data about the error
	 * @returns {string} The error message
	 */
	function processApiError(code, result) {
		switch(code) {
			case 'missingtitle':
				return MSG.apiPageDeleted;
			case 'http':
				switch(result.textStatus) {
					case 'abort':
						return MSG.apiAbort;
					case 'timeout':
						return MSG.apiTimeout;
					case 'error':
						if(result.exception === '') {
							return MSG.apiNetworkError;
						} else {
							// Doesn't occur for HTTP/2
							return MSG.apiHttpError.replace(/\$1/g, result.exception);
						}
					case 'parseerror':
						console.log(result.exception);
						return MSG.apiFormatError;
				}
		}
		// unknown, generic
		console.error(result);
		var resultInfo = api.getErrorMessage(result);
		resultInfo = resultInfo.text();

		return MSG.apiGeneralError.replace(/\$1/g, resultInfo);
	}

	install();
})(jQuery, mw);
// </nowiki>