Przejdź do zawartości

MediaWiki:Gadget-translation-editor.js

Z Wikisłownika – wolnego słownika wielojęzycznego

Uwaga: aby zobaczyć zmiany po opublikowaniu, może zajść potrzeba wyczyszczenia pamięci podręcznej przeglądarki.

  • Firefox / Safari: Przytrzymaj Shift podczas klikania Odśwież bieżącą stronę, lub naciśnij klawisze Ctrl+F5, lub Ctrl+R (⌘-R na komputerze Mac)
  • Google Chrome: Naciśnij Ctrl-Shift-R (⌘-Shift-R na komputerze Mac)
  • Edge: Przytrzymaj Ctrl, jednocześnie klikając Odśwież, lub naciśnij klawisze Ctrl+F5.
  • Opera: Naciśnij klawisze Ctrl+F5.
var gadget = {};

var PREFERRED_LANG_OPTION_KEY = 'userjs-translation-editor-preferred-lang';
var PREFERRED_LANG_STORAGE_KEY = 'ext.gadget.translation-editor.preferred-lang';
var POST_EDIT_STORAGE_KEY = 'ext.gadget.translation-editor.post-edit';

gadget.preferredLang = !mw.user.isAnon()
	? mw.user.options.get( PREFERRED_LANG_OPTION_KEY )
	: mw.storage.get( PREFERRED_LANG_STORAGE_KEY );

var EDITBOX_TOP_OFFSET = 100;
var EDITBOX_SCROLL_DURATION = 500;
var PREVIEW_SCROLL_DURATION = 500;
var SUMMARY_PREVIEW_DELAY = 250;

mw.libs.String = require( 'mediawiki.String' );
mw.libs.specialCharacters = require( 'mediawiki.language.specialCharacters' );
mw.libs.langData = require( 'ext.gadget.langdata' );

mw.messages.set( {
	'transl-parse-error':     'Nie udało się odczytać kodu strony. Przejdź na tryb edycji wikikodu i sprawdź, czy nie ma błędów składniowych.',
	'transl-validate-error':  'Lista tłumaczeń zawiera niewspierane języki albo błędy składniowe.',
	'transl-rename-success':  'Zmieniono nazwę języka „$1” na przyjęte w Wikisłowniku określenie „$2”.',
	'transl-rename-conflict': 'W Wikisłowniku nazwy „$1” oraz „$2” są tożsame; wykryto obie na liście tłumaczeń.',
	'transl-sorted-notice':   'Naprawiono kolejność tłumaczeń.',
	'transl-load-error':      'Istnieje nowsza wersja strony. Przeładuj ją i spróbuj ponownie.',
	'transl-invalid-code':    'Nie rozpoznano kodu języka: „$1”.',
	'transl-select-lang':     'Wybierz język:',
	'transl-lang-label':      'Tłumaczysz na<br><strong>$1</strong> <small>(<strong>nieznany kod</strong>)</small>',
	'transl-lang-label-iso':  'Tłumaczysz na<br><strong>$1</strong> <small>(<a href="/wiki/Wikis%C5%82ownik:Kody_j%C4%99zyk%C3%B3w" target="_blank">$2</a>)</small>',
	'transl-load-button':     'Załaduj',
	'transl-save-button':     'Zapamiętaj',
	'transl-forget-button':   'Zapomnij',
	'transl-save-save':       'Zapisz',
	'transl-save-cancel':     'Anuluj',
	'transl-prompt-title':    'Ustawienia domyślnego języka',
	'transl-save-prompt':     'Język o nazwie „$1” zostanie załadowany domyślnie podczas następnego uruchomienia edytora tłumaczeń.',
	'transl-save-current':    'Twoje obecne ustawienie to „$1”.',
	'transl-forget-prompt':   'Język o nazwie „$1” przestanie być ładowany domyślnie.',
	'transl-save-success':    'Zapisano preferencje.',
	'transl-rename-title':    'Zmiana nazwy języka',
	'transl-rename-desc':     'Wskazana nazwa języka zastąpi „$1”, zachowując obecne tłumaczenia.',
	'transl-rename-error':    '<strong>Błąd:</strong> niewspierany język albo już obecny na liście.',
	'transl-rename-apply':    'Zmień',
	'transl-rename-cancel':   'Anuluj',
	'transl-summary-preview': '($1)',
	'transl-summary-placeholder': 'opcjonalne uzasadnienie wykonanych zmian, np. wskazanie użytych źródeł',
	'transl-summary-desc':    'Opis zmian:',
	'transl-summary-edit':    '(edytuj)',
	'transl-compare-title':   'Porównanie wersji',
	'transl-compare-old':     'Aktualna wersja',
	'transl-compare-new':     'Wersja po zapisaniu zmian',
	'transl-clear-title':     'Anulowanie zmian',
	'transl-clear-prompt':    'Czy na pewno chcesz wycofać wprowadzone zmiany i rozpocząć edycję od nowa?',
	'transl-clear-confirm':   'Potwierdź',
	'transl-clear-cancel':    'Anuluj',
	'transl-apply-button':    'Pokaż podgląd',
	'transl-compare-button':  'Wykaz zmian',
	'transl-submit-button':   'Zapisz',
	'transl-clear-button':    'Wyczyść',
	'transl-draft-change':    '(edytuj)',
	'transl-draft-remove':    '(usuń)',
	'transl-draft-restore':   '(przywróć)',
	'transl-draft-rename':    '(język)',
	'transl-draft-abort':     '(wstrzymaj)',
	'transl-keyboard':        '(klawiatura&nbsp;ekranowa)',
	'transl-keyboard-close':  'Zamknij',
	'transl-rcf':             '(<a href="//pl.wiktionary.org/wiki/Specjalna:Pusta_strona/ostatnie_t%C5%82umaczenia" target="_blank">ostatnie&nbsp;zmiany</a>)',
	'transl-report-error':    '(<a href="//pl.wiktionary.org/w/index.php?title=Wikidyskusja:Narz%C4%99dzia/Edytor_t%C5%82umacze%C5%84&action=edit&section=new&editintro=Wikidyskusja:Narz%C4%99dzia/Edytor_t%C5%82umacze%C5%84/Nag%C5%82%C3%B3wek&nosummary=" target="_blank">zgłoś&nbsp;problem</a>)',
	'transl-help':            '(<a href="//pl.wiktionary.org/wiki/Wikis%C5%82ownik:Narz%C4%99dzia/Edytor_t%C5%82umacze%C5%84/Przewodnik" target="_blank">pomoc</a>)',
	'transl-lang-suggest-code': 'Język z kodem <strong>$1</strong>',
	'transl-forbidden-lang':  'Narzędzie nie wspiera tłumaczeń na język: „$1”.',
	'transl-confirm-close':   'Masz niezapisane tłumaczenia. Czy na pewno chcesz opuścić stronę?',
	'transl-alert-pending':   'Ostatnie zmiany w tłumaczeniach na „$1” nie zostaną zapisane. Wróć i przyciśnij „$2”, aby je uwzględnić.',
	'transl-preview-parsing': 'poczekaj, trwa parsowanie wikikodu',
	'transl-summary-renamed': 'przemianowano $1',
	'transl-summary-rename-mapping': '$1 na $2',
	'transl-summary-added':   'dodano $1',
	'transl-summary-modified': 'zmodyfikowano $1',
	'transl-summary-deleted': 'usunięto $1',
	'transl-summary-sorted':  'sortowanie',
	'transl-load-tooltip':    'Rozpocznij edycję wybranego języka',
	'transl-save-tooltip':    'Automatycznie rozpoczynaj edycję wybranego języka przy następnym uruchomieniu',
	'transl-forget-tooltip':  'Nie rozpoczynaj edycji zapamiętanego języka przy następnym uruchomieniu',
	'transl-keyboard-tooltip': 'Otwórz klawiaturę ekranową',
	'transl-rcf-tooltip':     'Przeglądaj ostatnio zmienione tłumaczenia',
	'transl-report-tooltip':  'Przejdź do strony zgłaszania problemów (otwiera się w nowej zakładce)',
	'transl-help-tooltip':    'Przejdź do strony z pomocą (otwiera się w nowej zakładce)',
	'transl-apply-tooltip':   'Wygeneruj podgląd wprowadzonych tłumaczeń na poniższej liście',
	'transl-compare-tooltip': 'Porównaj dotychczasową wersję tłumaczeń z wprowadzonymi właśnie zmianami',
	'transl-submit-tooltip':  'Zapisz zmiany i przeładuj stronę [Ctrl+Enter]',
	'transl-clear-tooltip':   'Przywróć formularz do stanu pierwotnego',
	'transl-summary-tooltip': 'Odsłoń pole do wprowadzania opisu zmian',
	'transl-draft-change-tooltip': 'Edytuj tłumaczenia na ten język',
	'transl-draft-remove-tooltip': 'Oznacz język do usunięcia z listy tłumaczeń',
	'transl-draft-restore-tooltip': 'Nie usuwaj tego języka z listy tłumaczeń',
	'transl-draft-rename-tooltip': 'Zmień nazwę języka, zachowując tłumaczenia',
	'transl-draft-abort-tooltip': 'Anuluj zapytanie i pokaż surowy wikitekst'
} );

var config = mw.config.get( [
	'wgPageName',
	'wgRevisionId',
	'wgArticleId',
	'wgCommentCodePointLimit',
	'wgUserLanguage'
] );

var api = new mw.Api( { parameters: {
	formatversion: 2,
	errorformat: 'html',
	errorlang: config.wgUserLanguage,
	errorsuselocal: true
} } );

var forbiddenLanguageNames = [ 'polski', 'użycie międzynarodowe' ];

var forbiddenLanguageCodes = null;

var forbiddenTranslations = [ 'polski język migowy' ];

var softStressMarkLanguages = [ 'białoruski', 'białoruski (taraszkiewica)', 'bułgarski', 'karpatorusiński', 'rosyjski', 'ukraiński' ];

function init( $transl, $defn, $button ) {
	var $translList = $transl.parent().next( 'ul' );
	
	gadget.activeLang = '';
	gadget.drafts = {};
	gadget.draftMetadata = {};
	
	forbiddenLanguageCodes = forbiddenLanguageNames.filter( function ( lang ) {
		return lang in mw.libs.langData.lang2code;
	} ).map( function ( lang ) {
		return mw.libs.langData.lang2code[ lang ];
	} );
	
	return $.when(
		api.get( {
			prop: 'revisions',
			rvprop: [ 'timestamp', 'content', 'ids' ],
			rvslots: 'main',
			titles: config.wgPageName,
			curtimestamp: true
		} ),
		api.getMessages( [ 'wikimedia-copyrightwarning' ] ).then( function ( messages ) {
			return api.parse( messages[ 'wikimedia-copyrightwarning' ], {
				prop: 'text',
				wrapoutputclass: '',
				disablelimitreport: true
			} ).done( function ( text ) {
				var $p = $( text ).first( 'p' );
				mw.messages.set( 'transl-submit-legal', $p.html().trim() );
			} );
		} ),
		!!Number( mw.user.options.get( 'gadget-false-blue-links' ) )
			? mw.loader.using( 'ext.gadget.false-blue-links' ).done( function ( require ) {
				mw.libs.falseBlueLinks = require( 'ext.gadget.false-blue-links' );
			} )
			: null,
		!!Number( mw.user.options.get( 'gadget-term-preview' ) )
			? mw.loader.using( 'ext.gadget.term-preview' ).done( function ( require ) {
				mw.libs.termPreview = require( 'ext.gadget.term-preview' );
			} )
			: null
	).fail( function ( code, data ) {
		mw.notify( api.getErrorMessage( data ), { type: 'error' } );
	} ).then( function ( res ) {
		var revision = res[ 0 ].query.pages[ 0 ].revisions[ 0 ],
			deferred = $.Deferred();
		
		if ( revision.revid !== config.wgRevisionId ) {
			deferred.reject( mw.msg( 'transl-load-error' ) );
		} else {
			gadget.content = revision.slots.main.content;
			gadget.starttimestamp = res.curtimestamp;
			gadget.basetimestamp = revision.timestamp;
			
			if ( analyzePage( $translList, $defn ) ) {
				deferred.resolve( createMenu( $transl, $defn ) );
			} else {
				deferred.reject( mw.msg( 'transl-parse-error' ) );
			}
		}
		
		return deferred.promise();
	} ).done( function ( gui ) {
		gui.$wrapper.fadeIn();
		$translList.hide();
		
		$button.on( 'click', function () {
			$translList.toggle();
			gui.$wrapper.toggle();
			gui.$langSelector.trigger( 'focus' );
		} );
		
		if ( gui.$wrapper.offset().top - $( window ).scrollTop() > EDITBOX_TOP_OFFSET ) {
			$( 'html, body' ).animate( {
				scrollTop: gui.$wrapper.offset().top - EDITBOX_TOP_OFFSET
			}, EDITBOX_SCROLL_DURATION );
		}
	} ).fail( function ( message ) {
		mw.notify( message, { type: 'error' } );
		$button.hide();
	} );
}

function analyzePage( $translList, $defn ) {
	var a2, a3, a4, b, langSection, translations, langs, drafts, metadata,
		a = gadget.content.indexOf( ' ({' + '{język polski' );
	
	if ( a === -1 ) {
		return false;
	}
	
	b = gadget.content.indexOf( '\n== ', a );
	b = ( b !== -1 ) ? b : gadget.content.length;
	langSection = gadget.content.slice( 0, b );
	a2 = langSection.indexOf( '{' + '{tłumaczenia}}\n', a );
	b = langSection.indexOf( '{' + '{źródła}}', a2 );
	
	if ( a2 === -1 || b === -1 ) {
		return false;
	}
	
	translations = langSection.slice( 0, b );
	a3 = translations.indexOf( '\n*', a2 );
	
	if (
		a3 !== -1 &&
		translations.slice( a3 ).indexOf( '{{zobtłum' ) !== -1
	) {
		a3 = translations.lastIndexOf( '{{zobtłum' );
		a3 = translations.indexOf( '\n*', a3 );
	}
	
	if ( a3 !== -1 ) {
		langs = [];
		drafts = {};
		metadata = {};
		
		translations.slice( a3 + 1, b ).split( '\n' ).forEach( function ( line, i ) {
			var lang, terms,
				res = line.match( /^\* *([^:]+): *(.+)/ );
			
			if ( res && res[ 1 ] && res[ 2 ] ) {
				lang = res[ 1 ].trim();
				terms = res[ 2 ].trim();
				langs.push( lang );
				metadata[ lang ] = {
					$original: $translList.children().eq( i ),
					terms: terms
				};
				
				if ( forbiddenTranslations.indexOf( lang ) !== -1 ) {
					drafts[ lang ] = terms;
					metadata[ lang ].unsupported = true;
				} else {
					drafts[ lang ] = parseTerms( terms ) || terms;
				}
			}
		} );
		
		if (
			!langs.length || langs.length !== Object.keys( drafts ).length ||
			langs.length !== $translList.children().length
		) {
			return false;
		}
		
		normalizeDrafts( drafts, metadata );
		
		if ( !validateDrafts( $defn, drafts, metadata ) ) {
			mw.notify( mw.msg( 'transl-validate-error' ), { type: 'warn' } );
		}
		
		gadget.drafts = drafts;
		gadget.draftMetadata = metadata;
		a3++;
	} else {
		a3 = b;
	}
	
	gadget.initialDrafts = $.extend( {}, gadget.drafts );
	gadget.startIndex = a3;
	gadget.endIndex = b;
	
	gadget.drafts = sortByLanguage( gadget.drafts );
	
	if ( testSortDivergence() ) {
		mw.notify( mw.msg( 'transl-sorted-notice' ) );
	}
	
	return true;
}

function parseTerms( terms ) {
	var obj = {},
		reTerm = /^(\(\d+\.\d+\)) *(.+)/,
		reSubterm = /^\[\[([^\]]+?)\]\](?: +\{\{([^\}]+?)\}\})?$/;
	
	terms.split( / *; */ ).forEach( function ( term ) {
		var m = term.match( reTerm );
		
		if ( obj === null || m === null || m[ 1 ] in obj ) {
			obj = null;
			return;
		}
		
		obj[ m[ 1 ] ] = m[ 2 ].trim().split( / *, */ ).map( function ( subterm ) {
			var base, template,
				mm = subterm.match( reSubterm );
			
			if ( mm !== null ) {
				base = mw.format( '[' + '[$1]]', mm[ 1 ].trim() );
				template = mm[ 2 ] && mw.format( '{' + '{$1}}', mm[ 2 ].trim() );
				
				if ( template === '{' + '{f}}' ) {
					// [[Special:PermaLink/7109640#Hiperonimy i hiponimy oraz femininum.]]
					template = '{' + '{ż}}';
				}
			} else {
				base = subterm;
			}
			
			return { base: base, template: template };
		} );
	} );
	
	return obj;
}

function renameDraftLanguage( oldName, newName, drafts, draftMetadata ) {
	drafts = drafts || gadget.drafts;
	draftMetadata = draftMetadata || gadget.draftMetadata;
	
	drafts[ newName ] = drafts[ oldName ]; 
	draftMetadata[ newName ] = draftMetadata[ oldName ];
	
	if ( draftMetadata[ newName ].editState === 'added' ) {
		// no-op
	} else if ( !( 'renamedFrom' in draftMetadata[ newName ] ) ) {
		draftMetadata[ newName ].renamedFrom = oldName;
	} else if ( draftMetadata[ newName ].renamedFrom === newName ) {
		delete draftMetadata[ newName ].renamedFrom;
	}
	
	delete drafts[ oldName ];
	delete draftMetadata[ oldName ];
	
	issueParseRequest( newName, drafts[ newName ], draftMetadata[ newName ] );
}

function normalizeDrafts( drafts, metadata ) {
	Object.keys( drafts ).map( function ( draftLang ) {
		var re = new RegExp( mw.format( '^$1$', mw.util.escapeRegExp( draftLang ) ), 'i' ),
			aliases = [].concat( Object.keys( mw.libs.langData.aliases ).filter( function ( lang ) {
				return re.test( lang );
			} ).map( function ( lang ) {
				return mw.libs.langData.aliases[ lang ];
			} ), Object.keys( mw.libs.langData.lang2code ).filter( function ( lang ) {
				return re.test( lang );
			} ) );
		
		return aliases.length ? { original: draftLang, replacement: aliases[ 0 ] } : null;
	} ).filter( function ( data ) {
		return data && data.original !== data.replacement;
	} ).forEach( function ( data ) {
		if ( data.replacement in drafts ) {
			metadata[ data.original ].duplicate = true;
			mw.notify( mw.msg( 'transl-rename-conflict', data.original, data.replacement ), { type: 'warn' } );
		} else {
			renameDraftLanguage( data.original, data.replacement, drafts, metadata );
			metadata[ data.replacement ].normalizedTo = data.replacement;
			mw.notify( mw.msg( 'transl-rename-success', data.original, data.replacement ) );
		}
	} );
}

function validateDrafts( $defn, drafts, metadata ) {
	Object.keys( drafts ).filter( function ( lang ) {
		return !(
			'broken' in metadata[ lang ] ||
			'unsupported' in metadata[ lang ]
		);
	} ).forEach( function ( lang ) {
		if (
			!$.isPlainObject( drafts[ lang ] ) ||
			$.isEmptyObject( drafts[ lang ] ) ||
			!validateDraft( $defn, drafts[ lang ] )
		) {
			metadata[ lang ].broken = true;
		}
	} );
	
	return !Object.keys( metadata ).some( function ( lang ) {
		return 'broken' in metadata[ lang ];
	} );
}

function validateDraft( $defn, draft ) {
	var reNum = /\((\d+)\.(\d+)\)/,
		draftKeys = Object.keys( draft );
	
	return draftKeys.every( function ( term ) {
		return $defn.filter( function () {
				return $( this ).text() === term;
			} ).length && !draft[ term ].some( function ( subterm ) {
				return reNum.test( subterm.base );
			} );
	} ) && draftKeys.slice().sort( function ( a, b ) {
		var ma = reNum.exec( a ),
			mb = reNum.exec( b );
		
		return ( ma[ 1 ] - mb[ 1 ] ) || ( ma[ 2 ] - mb[ 2 ] );
	} ).every( function ( value, index ) {
		return value === draftKeys[ index ];
	} );
}

function comparatorPl( a, b ) {
	// ignore spaces and hyphens (dictionary order): https://w.wiki/bkn
	a = a.replace( / /g, '' ).replace( /-/g, '' );
	b = b.replace( / /g, '' ).replace( /-/g, '' );
	return a.localeCompare( b, 'pl' );
}

function prepareDraft() {
	return Object.keys( gadget.drafts ).filter( function ( lang ) {
		return gadget.draftMetadata[ lang ].editState !== 'deleted';
	} ).map( function ( lang ) {
		return mw.format( '* $1: $2\n', lang, serializeDraft( gadget.drafts[ lang ] ) );
	} ).join( '' );
}

function resetForms( gui, clearSummary ) {
	gadget.activeLang = null;
	gui.$langLabel.html( mw.msg( 'transl-select-lang' ) );
	gui.$langSelector.val( '' ).trigger( 'focus' );
	gui.$saveButton.prop( 'disabled', true );
	gui.$textInputs.find( 'input' ).val( '' ).prop( 'disabled', true );
	gui.$textArea.find( 'textarea' ).val( '' );
	gui.$apply.prop( 'disabled', true );
	
	if ( clearSummary ) {
		gui.$summaryEdit.show();
		gui.$summaryInput.hide();
		gui.$summaryPreview.text( mw.msg( 'transl-summary-preview', '' ) );
	}
}

function resetSubmitButtons( gui ) {
	var hasChanged = hasPendingChanges();
	gui.$diff.prop( 'disabled', !hasChanged );
	gui.$submit.prop( 'disabled', !hasChanged );
	gui.$clear.prop( 'disabled', !hasChanged );
}

function hasPendingChanges() {
	return Object.keys( gadget.draftMetadata ).some( function ( lang ) {
		var metadata = gadget.draftMetadata[ lang ];
		return 'editState' in metadata || 'renamedFrom' in metadata;
	} ) || testSortDivergence();
}

function testDraftDivergence( lang ) {
	var initial,
		metadata = gadget.draftMetadata[ lang ];
	
	if ( 'renamedFrom' in metadata ) {
		initial = gadget.initialDrafts[ metadata.renamedFrom ];
	} else {
		initial = gadget.initialDrafts[ lang ];
	}
	
	return JSON.stringify( initial ) !== JSON.stringify( gadget.drafts[ lang ] );
}

function testSortDivergence() {
	var renamedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
			return 'renamedFrom' in gadget.draftMetadata[ lang ];
		} ).map( function ( lang ) {
			return gadget.draftMetadata[ lang ].renamedFrom;
		} ),
		normalizedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
			return 'normalizedTo' in gadget.draftMetadata[ lang ];
		} ).map( function ( lang ) {
			return gadget.draftMetadata[ lang ].normalizedTo;
		} ),
		deletedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
			return gadget.draftMetadata[ lang ].editState === 'deleted';
		} ),
		unsortedInitial = Object.keys( gadget.initialDrafts ).filter( function ( lang ) {
			return renamedLangs.indexOf( lang ) === -1
				&& normalizedLangs.indexOf( lang ) === -1
				&& deletedLangs.indexOf( lang ) === -1;
		} ),
		processedInitial = Object.keys( gadget.drafts ).filter( function ( lang ) {
			return unsortedInitial.indexOf( lang ) !== -1;
		} );
	
	// assertion: unsortedInitial.length === processedInitial.length
	return processedInitial.some( function ( value, index ) {
		return value !== unsortedInitial[ index ];
	} );
}

function onLoadLang( gui, targetLang, evt ) {
	var isBrokenTranslation,
		lang = targetLang || gui.$langSelector.val().trim();
	
	if ( evt ) {
		evt.preventDefault();
	}
	
	if ( !lang ) {
		return;
	}
	
	resetForms( gui );
	
	if ( lang in mw.libs.langData.aliases ) {
		lang = mw.libs.langData.aliases[ lang ];
	}
	
	if ( forbiddenLanguageNames.indexOf( lang ) !== -1 ) {
		mw.notify( mw.msg( 'transl-forbidden-lang', lang ), { type: 'error' } );
		return;
	}
	
	isBrokenTranslation = lang in gadget.draftMetadata && 'broken' in gadget.draftMetadata[ lang ];
	
	gadget.activeLang = lang;
	
	gui.$langLabel.html( lang in mw.libs.langData.lang2code
		? mw.msg( 'transl-lang-label-iso', lang, mw.libs.langData.lang2code[ lang ] )
		: mw.msg( 'transl-lang-label', lang )
	);
	
	if ( !( lang in mw.libs.langData.lang2code ) ) {
		mw.notify( mw.msg( 'transl-invalid-code', lang ), { type: 'warn' } );
	}
	
	gui.$saveButton
		.prop( 'disabled', false )
		.attr( gadget.preferredLang === lang ? {
			value: mw.msg( 'transl-forget-button' ),
			title: mw.msg( 'transl-forget-tooltip' )
		} : {
			value: mw.msg( 'transl-save-button' ),
			title: mw.msg( 'transl-save-tooltip' )
		} );
	
	if ( isBrokenTranslation ) {
		gui.$textInputs.hide();
		gui.$textArea.show().find( 'textarea' ).val( gadget.draftMetadata[ lang ].terms );
	} else {
		gui.$textInputs.show().find( 'input' ).prop( 'disabled', false );
		gui.$textArea.hide();
		
		if ( lang in gadget.drafts ) {
			gui.$textInputs.children().each( loadDrafts.bind( null, gadget.drafts[ lang ] ) );
		}
	}
	
	selectPreviewItem( gui, lang );
	gui.$apply.prop( 'disabled', false );
	
	setTimeout( function () {
		var $el = isBrokenTranslation ? gui.$textArea.find( 'textarea' ) : gui.$textInputs.find( 'input' ).first();
		$el.trigger( 'focus' );
	}, 1 );
}

function loadDrafts( draft, i, el ) {
	var $el = $( el ),
		defn = $el.data( 'defn' ),
		$inputs = $el.children( 'input' ),
		$text = $inputs.first(),
		$tmpl = $inputs.last(),
		text = [],
		tmpl = [];
	
	if ( !draft[ defn ] ) {
		return true;
	}
	
	draft[ defn ].forEach( function ( obj ) {
		text.push( stripBrackets( obj.base ) );
		tmpl.push( stripBrackets( obj.template || '' ) );
	} );
	
	$text.val( text.join( ', ' ) );
	
	if ( tmpl.join( '' ) !== '' ) {
		$tmpl.val( tmpl.join( ',' ) );
	}
}

function onSaveLang( gui, evt ) {
	var message, updateLang, newValue;
	
	if ( gadget.activeLang !== gadget.preferredLang ) {
		message = mw.msg( 'transl-save-prompt', gadget.activeLang );
		
		if ( !!gadget.preferredLang ) {
			message += ' ' + mw.msg( 'transl-save-current', gadget.preferredLang );
		}
		
		updateLang = true;
	} else {
		message = mw.msg( 'transl-forget-prompt', gadget.preferredLang );
		updateLang = false;
	}
	
	newValue = updateLang ? gadget.activeLang : null;
	
	gui.$confirmDialog.append( message ).data( {
		newValue: newValue,
		updateLang: updateLang
	} ).dialog( 'open' );
}

function savePreferredLangRequest( gui ) {
	var newValue = gui.$confirmDialog.data( 'newValue' ),
		updateLang = gui.$confirmDialog.data( 'updateLang' );
	
	function onSuccess() {
		gadget.preferredLang = newValue;
		
		if ( updateLang ) {
			gui.$saveButton.attr( {
				value: mw.msg( 'transl-forget-button' ),
				title: mw.msg( 'transl-forget-tooltip' )
			} );
		} else {
			gui.$saveButton.attr( {
				value: mw.msg( 'transl-save-button' ),
				title: mw.msg( 'transl-save-tooltip' )
			} );
		}
		
		mw.notify( mw.msg( 'transl-save-success' ) );
	}
	
	if ( !mw.user.isAnon() ) {
		return api.saveOption( PREFERRED_LANG_OPTION_KEY, newValue ).done( onSuccess );
	} else {
		mw.storage.set( PREFERRED_LANG_STORAGE_KEY, newValue );
		onSuccess();
		return $().promise();
	}
}

function stripBrackets( s ) {
	return s.replace( /^[\[\{]{2}([^\|\[\]\{\}]+?)[\]\}]{2}$/, '$1' );
}

function abortParseRequest( lang ) {
	var metadata = gadget.draftMetadata[ lang ];
	
	if ( metadata && 'request' in metadata ) {
		metadata.request.abort();
	}
}

function abortAllParseRequests() {
	Object.keys( gadget.draftMetadata ).forEach( function ( lang ) {
		if ( 'request' in gadget.draftMetadata[ lang ] ) {
			gadget.draftMetadata[ lang ].request.abort();
		}
	} );
}

function issueParseRequest( lang, draft, metadata ) {
	var apiPromise = api.get( {
		action: 'parse',
		text: typeof draft === 'object' ? serializeDraft( draft ) : draft,
		prop: [ 'text', 'links' ],
		wrapoutputclass: '',
		contentmodel: 'wikitext',
		disablelimitreport: true
	} );
	
	abortParseRequest( lang );
	
	metadata.request = apiPromise.then( function ( data ) {
		return parsedWikitextCallback( data, lang );
	}, function ( code, data ) {
		mw.notify( api.getErrorMessage( data ), { type: 'warn' } );
	} ).always( function () {
		delete metadata.request;
	} ).promise( {
		abort: apiPromise.abort
	} );
	
	return apiPromise;
}

function onApplyChanges( gui, $defn, evt ) {
	var $line,
		rawDraft = '',
		draft = {},
		metadata = gadget.draftMetadata[ gadget.activeLang ] || {},
		isFreshDraft = !( gadget.activeLang in gadget.drafts );
	
	if ( evt ) {
		evt.preventDefault();
	}
	
	if ( 'broken' in metadata ) {
		rawDraft = gui.$textArea.find( 'textarea' ).val();
		draft = parseTerms( rawDraft ) || {};
		
		if ( !$.isEmptyObject( draft ) && validateDraft( $defn, draft ) ) {
			gui.$textInputs.show().find( 'input' ).prop( 'disabled', false );
			gui.$textArea.hide();
			gui.$textInputs.children().each( loadDrafts.bind( null, draft ) );
			delete metadata.broken;
		} else {
			mw.notify( mw.msg( 'transl-validate-error' ), { type: 'warn' } );
			return;
		}
	} else {
		if (
			gui.$textInputs.children().filter( constructDrafts.bind( null, draft, gadget.activeLang ) ).length
				!== gui.$textInputs.children().length ||
			$.isEmptyObject( draft )
		) {
			return;
		}
	}
	
	gadget.drafts[ gadget.activeLang ] = !$.isEmptyObject( draft ) ? draft : rawDraft;
	
	if ( isFreshDraft ) {
		gadget.drafts = sortByLanguage( gadget.drafts );
		gadget.draftMetadata[ gadget.activeLang ] = metadata;
	}
	
	if ( !( 'renamedFrom' in metadata || gadget.activeLang in gadget.initialDrafts ) ) {
		metadata.editState = 'added';
	} else if ( testDraftDivergence( gadget.activeLang ) ) {
		metadata.editState = 'modified';
	} else {
		delete metadata.editState;
	}
	
	issueParseRequest( gadget.activeLang, gadget.drafts[ gadget.activeLang ], metadata );
	previewTranslation( gui, gadget.activeLang );
	previewSummary( gui );	
	resetSubmitButtons( gui );
	
	$line = selectPreviewItem( gui, gadget.activeLang );
	
	if ( $line.length ) {
		// https://stackoverflow.com/a/18927969
		gui.$previewbox.animate( {
			scrollTop: gui.$previewbox.scrollTop() - gui.$previewbox.offset().top + $line.offset().top
		}, PREVIEW_SCROLL_DURATION );
	}
}

function sortByLanguage( drafts ) {
	// https://stackoverflow.com/a/31102605
	var sortedCopy = {};
	
	Object.keys( drafts ).sort( comparatorPl ).forEach( function ( lang ) {
		sortedCopy[ lang ] = drafts[ lang ];
	} );
	
	return sortedCopy;
}

function constructDrafts( draft, lang, i, el ) {
	var terms, tmpls, temp, arr,
		$this = $( el ),
		$inputs = $this.children( 'input' ),
		text = $inputs.first().val().trim(),
		tmpl = $inputs.last().val().trim(),
		combAcuteAccent = String.fromCharCode( 0x301 ),
		success = true;
	
	if ( !text ) {
		return true;
	}
	
	terms = text.split( / *, */ );
	tmpls = tmpl ? tmpl.split( / *, */ ) : null;
	arr = [];
	
	terms.filter( function ( term ) {
		return term !== '';
	} ).map( function ( term ) {
		// replace tabs with spaces, delete soft hyphen marks
		return term.replace( /\t/g, ' ' ).replace( '\u00ad', '' );
	} ).forEach( function ( term, j ) {
		var base, template, temp2;
		
		if ( /[\[\]\(\)\{\}<>\/]/.test( term ) ) {
			base = term;
		} else if (
			softStressMarkLanguages.indexOf( lang ) !== -1 &&
			( term.indexOf( '`' ) !== -1 || term.indexOf( combAcuteAccent ) !== -1 )
		) {
			base = mw.format(
					'[' + '[$1|$2]]',
					term.replaceAll( '`', '' ).replaceAll( combAcuteAccent, '' ),
					term.replace( /`(.)/g, '$1' + combAcuteAccent )
				);
		} else {
			base = '[' + '[' + term + ']]';
		}
		
		if ( tmpls && tmpls[ j ] ) {
			temp2 = tmpls[ j ].split( / *\/ */ );
			
			if ( temp2.every( function ( el ) { return el.match( /[mfżnw]/ ); } ) ) {
				template = temp2.map( function ( el ) {
					return mw.format( '{' + '{$1}}', el );
				} ).join( '/' );
			} else if ( /[\[\]\(\)\{\}<>\/]/.test( tmpls[ j ] ) ) {
				template = tmpls[ j ];
			} else {
				template = '{' + '{' + tmpls[ j ] + '}}';
			}
			
			// [[Special:PermaLink/7109640#Hiperonimy i hiponimy oraz femininum.]]
			template = template.replace( /\{{2}f\}{2}/, '{' + '{ż}}' );
			
			if ( lang === 'niderlandzki' && template.indexOf( '{' + '{w}}' ) !== -1 ) {
				// uporczywe używanie kwalifikatora rodzaju wspólnego
				mw.notify( 'Nieprawidłowy rodzaj wspólny („w”) dla języka niderlandzkiego. Użyj „m”, „ż” lub „n”.', { type: 'error' } );
				success = false;
			}
		}
		
		arr.push( {
			base: base,
			template: template
		} );
	} );
	
	draft[ $this.data( 'defn' ) ] = arr;
	
	return success;
}

function parsedWikitextCallback( data, lang ) {
	var $parsed, $els, links;
	
	$parsed = $( data.parse.text.replace( '\n', '' ) ).first( 'p' );
	$els = $parsed.find( 'a' );
	
	if ( !$els.length || !( lang in mw.libs.langData.lang2code ) ) {
		return $parsed;
	}
	
	// TODO: użycie modułu sectionLinks
	$els.each( function () {
		var href = $( this ).attr( 'href' );
		
		if ( href.indexOf( '#' ) === -1 ) {
			href += '#' + mw.libs.langData.lang2code[ lang ];
			$( this ).attr( 'href', href );
		}
	} );
	
	if ( 'falseBlueLinks' in mw.libs ) {
		links = data.parse.links.filter( function ( obj ) {
			return obj.ns === 0 && obj.exists;
		} ).map( function ( obj ) {
			return obj.title;
		} );
		
		return mw.libs.falseBlueLinks.inspectTitles( links ).then( function () {
			mw.libs.falseBlueLinks.processElements( $parsed );
			return $parsed;
		} );
	} else {
		return $parsed;
	}
}

function serializeDraft( draft ) {
	var copy;
	
	if ( typeof( draft ) === 'string' ) {
		return draft;
	}
	
	copy = $.extend( true, {}, draft );
	
	return $.map( copy, function ( data, defn ) {
		return defn + ' ' + $.map( data, function ( word ) {
			return ( word.template
				? word.base + ' ' + word.template
				: word.base
			);
		} ).join( ', ' );
	} ).join( '; ' );
}

function previewTranslation( gui, lang, $li ) {
	var $preview, $serializedPreview,
		draft = gadget.drafts[ lang ],
		metadata = gadget.draftMetadata[ lang ],
		editStateClasses = [
			'transl-entry-added',
			'transl-entry-modified',
			'transl-entry-deleted'
		];
	
	$li = $li || gui.$preview.find( mw.format( '[data-lang="$1"]', lang ) );
	
	if ( !$li.length ) {
		$li = addPreviewItem( gui, lang, true );
	}
	
	$li.find( '.transl-preview-entry' ).prevAll().remove().end().replaceWith(
		$( '<span>' ).text( mw.format( '$1: ', lang ) ),
		$preview = $( '<span>' ).addClass( 'transl-preview-entry' )
	);
	
	$serializedPreview = $( '<code>' )
		.addClass( 'transl-preview-entry' )
		.text( serializeDraft( draft ) );
	
	if ( metadata.request ) {
		$li.find( '.transl-draft-change, .transl-draft-remove, .transl-draft-restore, .transl-draft-rename' ).hide();
		$li.find( '.transl-draft-abort' ).show();
		
		$preview.addClass( 'transl-parsing' ).text( mw.msg( 'transl-preview-parsing' ) );
		
		metadata.request.done( function ( $parsed ) {
			$preview.removeClass( 'transl-parsing' ).html( $parsed.html() );
		} ).fail( function () {
			$preview.replaceWith( $serializedPreview );
		} ).always( function () {
			$li.find( '.transl-draft-change, .transl-draft-remove, .transl-draft-rename' ).show();
			$li.find( '.transl-draft-restore, .transl-draft-abort' ).hide();
		} );
	} else {
		$preview.replaceWith( $serializedPreview );
	}
	
	if ( metadata.editState === 'added' ) {
		$li.removeClass( editStateClasses ).addClass( 'transl-entry-added' );
	} else if ( metadata.editState === 'deleted' ) {
		$li.removeClass( editStateClasses ).addClass( 'transl-entry-deleted' );
	} else if ( metadata.editState === 'modified' || 'renamedFrom' in metadata ) {
		// placed last to never override deletion status
		$li.removeClass( editStateClasses ).addClass( 'transl-entry-modified' );
	}
	
	if ( !( 'broken' in metadata ) ) {
		$li.removeClass( 'transl-entry-issues' );
	}
}

function makePreview( gui ) {
	gui.$preview.empty();
	
	Object.keys( gadget.drafts ).forEach( function ( lang ) {
		var $li = addPreviewItem( gui, lang, false );
		
		if ( 'renamedFrom' in gadget.draftMetadata[ lang ] ) {
			previewTranslation( gui, lang, $li );
		}
	} );
	
	// assertion: already sorted
}

function addPreviewItem( gui, lang, sort ) {
	var $items, sortFn,
		$li = makePreviewItem( gui, lang );
	
	gui.$preview.append( $li );
	
	if ( sort ) {
		$items = gui.$preview.children();
		sortFn = Array.prototype.sort.bind( $items );
		
		sortFn( function ( a, b ) {
			return comparatorPl( a.dataset.lang, b.dataset.lang );
		} );
		
		gui.$preview.append( $items );
	}
	
	return $li;
}

function selectPreviewItem( gui, lang ) {
	var $li = gui.$preview.find( mw.format( '[data-lang="$1"]', lang ) );
	
	gui.$preview.children()
		.removeClass( 'transl-active' )
		.find( '.transl-draft-change' ).removeClass( 'disabled' );
	
	$li
		.addClass( 'transl-active' )
		.find( '.transl-draft-change' ).addClass( 'disabled' );
	
	return $li;
}

function makePreviewItem( gui, lang ) {
	var $li, $preview, $change, $remove, $restore, $rename, $abort,
		metadata = gadget.draftMetadata[ lang ];
	
	$li = $( '<li>' ).attr( 'data-lang', lang );
	
	$change = $( '<small>' )
		.addClass( 'transl-draft-change' )
		.text( mw.msg( 'transl-draft-change' ) )
		.attr( 'title', mw.msg( 'transl-draft-change-tooltip' ) );
	
	$remove = $( '<small>' )
		.addClass( 'transl-draft-remove' )
		.text( mw.msg( 'transl-draft-remove' ) )
		.attr( 'title', mw.msg( 'transl-draft-remove-tooltip' ) );
	
	$restore = $( '<small>' )
		.addClass( 'transl-draft-restore' )
		.text( mw.msg( 'transl-draft-restore' ) )
		.attr( 'title', mw.msg( 'transl-draft-restore-tooltip' ) )
		.hide();
	
	$rename = $( '<small>' )
		.addClass( 'transl-draft-rename' )
		.text( mw.msg( 'transl-draft-rename' ) )
		.attr( 'title', mw.msg( 'transl-draft-rename-tooltip' ) );
	
	$abort = $( '<small>' )
		.addClass( 'transl-draft-abort' )
		.text( mw.msg( 'transl-draft-abort' ) )
		.attr( 'title', mw.msg( 'transl-draft-abort-tooltip' ) )
		.hide();
	
	$change.on( 'click', function () {
		if ( lang !== gadget.activeLang ) {
			gadget.activeLang = lang;
			onLoadLang( gui, lang );
		}
	} );
	
	$remove.on( 'click', function () {
		if ( lang in gadget.initialDrafts || 'renamedFrom' in metadata ) {
			metadata.editState = 'deleted';
			$li.addClass( 'transl-entry-deleted' );
			$remove.hide();
			$restore.show();
			$rename.hide();
		} else {
			delete gadget.drafts[ lang ];
			delete gadget.draftMetadata[ lang ];
			$li.remove();
			
			if ( lang === gadget.activeLang ) {
				resetForms( gui );
			}
		}
		
		previewSummary( gui );
		resetSubmitButtons( gui );
	} );
	
	$restore.on( 'click', function () {
		if ( lang in gadget.initialDrafts || 'renamedFrom' in metadata ) {
			if ( testDraftDivergence( lang ) ) {
				metadata.editState = 'modified';
			} else {
				delete metadata.editState;
			}
			
			$li.removeClass( 'transl-entry-deleted' );
			$remove.show();
			$restore.hide();
			$rename.show();
		}
		
		previewSummary( gui );
		resetSubmitButtons( gui );
	} );
	
	$rename.on( 'click', function () {
		gui.$renameDialog.data( 'activeLang', lang ).dialog( 'open' );
	} );
	
	$abort.on( 'click', function () {
		abortParseRequest( lang );
	} );
	
	$preview = $( '<span>' )
		.addClass( 'transl-preview-entry' )
		.appendTo( $li );
	
	if ( '$original' in metadata ) {
		$preview.html( metadata.$original.html() );
	}
	
	$( '<span>' )
		.addClass( 'transl-draft-actions' )
		.append( $change, $remove, $restore, $rename, $abort )
		.appendTo( $li );
	
	if ( 'broken' in metadata || 'duplicate' in metadata ) {
		$li.addClass( 'transl-entry-issues' );
	} else if ( 'unsupported' in metadata ) {
		$change.addClass( 'disabled' );
	}
	
	return $li;
}

function makeSummary( custom ) {
	var s, renamedMappings, renamedLangs, addedLangs, addedDrafts, deletedLangs, modifiedLangs,
		sortDivergence = testSortDivergence();
	
	custom = ( custom || '' ).trim();
	
	function buildString( renamed, added, modified, deleted, delimiter ) {
		var generated,
			out = [];
		
		if ( renamedLangs.length ) {
			out.push( mw.msg( 'transl-summary-renamed', renamed.join( ', ' ) ) );
		}
		
		if ( addedLangs.length ) {
			out.push( mw.msg( 'transl-summary-added', added.join( delimiter || ', ' ) ) );
		}
		
		if ( modifiedLangs.length ) {
			out.push( mw.msg( 'transl-summary-modified', modified.join( ', ' ) ) );
		}
		
		if ( deletedLangs.length ) {
			out.push( mw.msg( 'transl-summary-deleted', deleted.join( ', ' ) ) );
		}
		
		if ( sortDivergence ) {
			out.push( mw.msg( 'transl-summary-sorted' ) );
		}
		
		generated = out.join( ' •• ' );
		
		if ( !!custom && !!generated ) {
			return custom + '; ' + generated;
		} else {
			return custom + generated;
		}
	}
	
	renamedMappings = Object.keys( gadget.drafts ).filter( function ( lang ) {
		return 'renamedFrom' in gadget.draftMetadata[ lang ];
	} ).map( function ( to ) {
		var from = gadget.draftMetadata[ to ].renamedFrom;
		return mw.msg( 'transl-summary-rename-mapping', from, to );
	} );
	
	renamedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
		return 'renamedFrom' in gadget.draftMetadata[ lang ];
	} ).map( function ( lang ) {
		return gadget.draftMetadata[ lang ].renamedFrom;
	} );
	
	addedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
		return gadget.draftMetadata[ lang ].editState === 'added';
	} );
	
	addedDrafts = addedLangs.map( function ( lang ) {
		return mw.format( '$1: $2', lang, serializeDraft( gadget.drafts[ lang ] ) );
	} );
	
	modifiedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
		return gadget.draftMetadata[ lang ].editState === 'modified';
	} );
	
	deletedLangs = Object.keys( gadget.drafts ).filter( function ( lang ) {
		return gadget.draftMetadata[ lang ].editState === 'deleted';
	} );
	
	s = buildString( renamedMappings, addedDrafts, modifiedLangs, deletedLangs, ' • ' );
	
	if ( mw.libs.String.codePointLength( s ) > config.wgCommentCodePointLimit ) {
		s = buildString( renamedLangs, addedLangs, modifiedLangs, deletedLangs );
	}
	
	return s;
}

function trimSummary( summary ) {
	if ( mw.libs.String.codePointLength( summary ) > config.wgCommentCodePointLimit ) {
		summary = mw.libs.String.trimCodePointLength( '', summary, config.wgCommentCodePointLimit - 3 ).newVal;
		summary += '...';
	}
	
	return summary;
}

function testProcessedActiveLang( gui ) {
	var storedDraft = gadget.drafts[ gadget.activeLang ] || {},
		freshDraft = {};
	
	gui.$textInputs.children().each( constructDrafts.bind( null, freshDraft, gadget.activeLang ) );
	
	if ( JSON.stringify( freshDraft ) !== JSON.stringify( storedDraft ) ) {
		alert( mw.msg( 'transl-alert-pending', gadget.activeLang, mw.msg( 'transl-apply-button' ) ) );
		return false;
	}
	
	return true;
}

function onSubmit( gui ) {
	var summary = makeSummary( gui.$summaryInput.val() ),
		pageDraft = gadget.content.slice( 0, gadget.startIndex ) +
			prepareDraft() +
			gadget.content.slice( gadget.endIndex, gadget.content.length );
	
	gui.$submit.prop( 'disabled', true );
	gui.$summaryInput.prop( 'disabled', true );
	gui.$submitSpinner.css( 'visibility', 'visible' );
	
	api.postWithEditToken( api.assertCurrentUser( {
		action:        'edit',
		title:          config.wgPageName,
		text:           pageDraft,
		tags:          'translation-editor',
		summary:        trimSummary( summary ),
		notminor:       true,
		nocreate:       true,
		starttimestamp: gadget.starttimestamp,
		basetimestamp:  gadget.basetimestamp
	} ) )
	.done( function () {
		mw.storage.set( POST_EDIT_STORAGE_KEY, config.wgArticleId.toString() );
		location.reload();
	} )
	.fail( function ( code, data ) {
		gui.$submit.prop( 'disabled', false );
		gui.$summaryInput.prop( 'disabled', false );
		gui.$submitSpinner.css( 'visibility', 'hidden' );
		mw.notify( api.getErrorMessage( data ), { type: 'error' } );
	} );
}

function onCompare( gui ) {
	var newContent = gadget.content.slice( 0, gadget.startIndex ) +
		prepareDraft() +
		gadget.content.slice( gadget.endIndex, gadget.content.length );
	
	gui.$diffSpinner.css( 'visibility', 'visible' );
	
	gui.$compareDialog.data( 'parseRequest', api.post( {
		action: 'compare',
		fromrev: config.wgRevisionId,
		toslots: 'main',
		'totext-main': newContent
	} ).done( function ( res ) {
		var $diff = $( res.compare.body );
		gui.$compareTable.append( $diff );
		gui.$compareDialog.dialog( 'open' );
	} ).always( function () {
		gui.$compareDialog.removeData( 'parseRequest' );
		gui.$diffSpinner.css( 'visibility', 'hidden' );
	} ).fail( function ( code, data ) {
		mw.notify( api.getErrorMessage( data ), { type: 'warn' } );
	} ) );
}

// [mediawiki/extensions/WikiEditor] / modules / jquery.wikiEditor.toolbar.js
function buildCharacter( character, actions ) {
	if ( typeof character === 'string' ) {
		character = {
			label: character,
			action: {
				type: 'replace',
				options: {
					peri: character,
					selectPeri: false
				}
			}
		};
	// In some cases the label for the character isn't the same as the
	// character that gets inserted (e.g. Hebrew vowels)
	} else if ( character && 0 in character && 1 in character ) {
		character = {
			label: character[ 0 ],
			action: {
				type: 'replace',
				options: {
					peri: character[ 1 ],
					selectPeri: false
				}
			}
		};
	}
	
	if ( character && 'action' in character && 'label' in character ) {
		actions[ character.label ] = character.action;
		
		if ( character.titleMsg !== undefined ) {
			return mw.html.element( 'span', {
				rel: character.label,
				title: mw.msg( character.titleMsg )
			}, character.label );
		} else {
			return mw.html.element( 'span', {
				rel: character.label
			}, character.label );
		}
	}
	
	mw.log( 'A character for the toolbar was undefined. This is not supposed to happen. Double check the config.' );
	// bug 31673; also an additional fix for bug 24208...
	return '';
}

function unicodeToAscii( s ) {
	var n, ch, index,
		out = '',
		utf   = 'ąåãáćçęèłńóõüśźż',
		ascii = 'aaaacceelnoouszz';
	
	for ( n = 0; n < s.length; n++ ) {
		ch = s.charAt( n );
		index = utf.indexOf( ch );
		
		if ( index !== -1 ) {
			out += ascii.charAt( index );
		} else {
			out += ch;
		}
	}
	
	return out;
}

function previewSummary( gui ) {
	var summary = makeSummary( gui.$summaryInput.val() );
	
	return api.get( {
		action: 'parse',
		summary : trimSummary( summary ),
		prop: ''
	} ).done( function ( res ) {
		gui.$summaryPreview.html( mw.msg( 'transl-summary-preview', res.parse.parsedsummary ) );
	} ).fail( function ( code, data ) {
		if ( !( code === 'http' && data && data.textStatus === 'abort' ) ) {
			mw.notify( api.getErrorMessage( data ), { type: 'warn' } );
		}
	} ).always( function () {
		gui.$summaryInput.removeData( 'parseRequest' );
	} );
}

function createMenu( $transl, $defn ) {
	var allowCloseWindow, langSelectorConfig,
		gui = {},
		$activeTextInput = $( [] ),
		charActions = {};
	
	allowCloseWindow = mw.confirmCloseWindow( {
		test: hasPendingChanges,
		message: mw.msg( 'transl-confirm-close' ),
		namespace: 'translationeditor-editwarning'
	} );
	
	gui.$langSelector = $( '<input>' )
		.attr( {
			id: 'transl-langselector',
			type: 'text',
			size: 22,
			autocapitalize: 'off'
		} );
	
	gui.$textInputs = $( '<div>' )
		.attr( 'id', 'transl-textinputs' );
	
	gui.$textArea = $( '<div>' )
		.attr( 'id', 'transl-textarea' )
		.append( $( '<textarea>' )
			.attr( {
				rows: 6,
				cols: 71
			} )
		)
		.hide();
	
	$defn.each( function ( i, el ) {
		var $num,
			$el = $( el );
		
		$( '<div>' )
			.data( 'defn', $el.text() )
			.append(
				$num = $( '<span>' )
					.addClass( 'transl-def-label' )
					.text( $el.text() ),
				$( '<input>' )
					.attr( {
						type: 'text',
						size: 50
					} ),
				$( '<input>' )
					.attr( {
						type: 'text',
						size: 1
					} )
			)
			.appendTo( gui.$textInputs );
		
		if ( 'termPreview' in mw.libs ) {
			mw.libs.termPreview.enablePreview( $num, 'pl' );
		}
	} );
	
	gui.$loadButton = $( '<input>' )
		.attr( {
			type: 'button',
			value: mw.msg( 'transl-load-button' ),
			title: mw.msg( 'transl-load-tooltip' )
		} )
		.on( 'click', onLoadLang.bind( this, gui, null ) );
	
	gui.$saveButton = $( '<input>' )
		.attr( {
			type: 'button',
			value: mw.msg( 'transl-save-button' ),
			title: mw.msg( 'transl-save-tooltip' )
		} )
		.on( 'click', onSaveLang.bind( this, gui ) );
	
	gui.$apply = $( '<input>' )
		.attr( {
			type: 'button',
			value: mw.msg( 'transl-apply-button' ),
			title: mw.msg( 'transl-apply-tooltip' )
		} )
		.on( 'click', onApplyChanges.bind( this, gui, $defn ) );
	
	gui.$submitSpinner = $.createSpinner().css( 'margin-right', '1em' ).hide();
	
	gui.$compareTable = $( '<table>' ).addClass( [
		'diff', 'diff-editfont-' + mw.user.options.get( 'editfont' )
	] ).append(
		$( '<col>' ).addClass( 'diff-marker' ),
		$( '<col>' ).addClass( 'diff-content' ),
		$( '<col>' ).addClass( 'diff-marker' ),
		$( '<col>' ).addClass( 'diff-content' ),
		$( '<tr>' ).addClass( 'diff-title' ).append(
			$( '<td>' ).addClass( 'diff-otitle' ).attr( 'colspan', 2 ).text( mw.msg( 'transl-compare-old' ) ),
			$( '<td>' ).addClass( 'diff-ntitle' ).attr( 'colspan', 2 ).text( mw.msg( 'transl-compare-new' ) )
		)
	);
	
	gui.$compareDialog = $( '<div>' ).addClass( 'transl-compare-wrapper' ).dialog( {
		resizable: false,
		modal: true,
		autoOpen: false,
		width: 1000,
		title: mw.msg( 'transl-compare-title' ),
		close: function ( event, ui ) {
			gui.$compareTable.find( 'tr' ).first().nextAll().remove();
		}
	} ).append( gui.$compareTable );
	
	gui.$submitSpinner = $.createSpinner().css( 'visibility', 'hidden' );
	
	gui.$submit = $( '<input>' )
		.attr( {
			type: 'button',
			value: mw.msg( 'transl-submit-button' ),
			title: mw.msg( 'transl-submit-tooltip' )
		} )
		.prop( 'disabled', true )
		.on( 'click', function ( evt ) {
			abortAllParseRequests();
			
			if ( !gadget.activeLang || testProcessedActiveLang( gui ) ) {
				allowCloseWindow.release();
				onSubmit( gui );
			}
		} );
	
	gui.$diffSpinner = $.createSpinner().css( 'visibility', 'hidden' );
	
	gui.$diff = $( '<input>' )
		.attr( {
			type: 'button',
			value: mw.msg( 'transl-compare-button' ),
			title: mw.msg( 'transl-compare-tooltip' )
		} )
		.prop( 'disabled', true )
		.on( 'click', function ( evt ) {
			abortAllParseRequests();
			onCompare( gui );
		} );
	
	gui.$clearDialog = $( '<p>' ).text( mw.msg( 'transl-clear-prompt' ) ).dialog( {
		resizable: false,
		modal: true,
		autoOpen: false,
		draggable: false,
		title: mw.msg( 'transl-clear-title' ),
		dialogClass: 'transl-dialog-no-close',
		buttons: [
			{
				text: mw.msg( 'transl-clear-confirm' ),
				click: function () {
					analyzePage( gui.$wrapper.next( 'ul' ), $defn );
					resetForms( gui, true );
					resetSubmitButtons( gui );
					makePreview( gui );
					previewSummary( gui );
					gui.$clearDialog.dialog( 'close' );
				}
			},
			{
				text: mw.msg( 'transl-clear-cancel' ),
				click: function () {
					gui.$clearDialog.dialog( 'close' );
				}
			}
		]
	} );
	
	gui.$clear = $( '<input>' )
		.attr( {
			type: 'button',
			value: mw.msg( 'transl-clear-button' ),
			title: mw.msg( 'transl-clear-tooltip' )
		} )
		.prop( 'disabled', true )
		.on( 'click', function ( evt ) {
			abortAllParseRequests();
			gui.$clearDialog.dialog( 'open' );
		} );
	
	gui.$specialCharsButton = $( '<a>' )
		.attr( {
			href: '#',
			title: mw.msg( 'transl-keyboard-tooltip' )
		} )
		.html( mw.msg( 'transl-keyboard' ) )
		.on( 'click', function () {
			gui.$keyboard.show();
			return false;
		} );
	
	gui.$previewbox = $( '<div>' )
		.attr( 'id', 'transl-preview' )
		.append( gui.$preview = $( '<ul>' ) );
	
	gui.$keyboard = $( '<div>' )
		.attr( 'id', 'transl-keyboard' )
		.hide()
		.append(
			gui.$keyboardSelect = $( '<select>' ),
			$( '<input>' )
				.attr( {
					type: 'button',
					value: mw.msg( 'transl-keyboard-close' )
				} )
				.on( 'click', function ( evt ) {
					evt.preventDefault();
					gui.$keyboard.hide();
				} ),
			gui.$keyboardChars = $( '<div>' )
				.attr( 'id', 'transl-special-chars' )
		)
		.appendTo( mw.util.$content );
	
	$.each( mw.libs.specialCharacters, function ( group, chars ) {
		var $group = $( '<div>' )
			.hide()
			.attr( 'data-group', group )
			.appendTo( gui.$keyboardChars );
		
		$( '<option>' )
			.attr( 'value', group )
			.text( mw.msg( 'special-characters-group-' + group ) )
			.appendTo( gui.$keyboardSelect );
		
		chars.forEach( function ( character ) {
			$( buildCharacter( character, charActions ) ).appendTo( $group );
		} );
	} );
	
	gui.$keyboardSelect.on( 'change', function () {
		var group = $( this ).find( ':selected' ).attr( 'value' );
		gui.$keyboardChars.children().hide();
		gui.$keyboardChars.find( '[data-group=' + group + ']' ).show();
		mw.storage.set( 'ext.gadget.translation-editor.keyboard-group', group );
	} );
	
	gui.$keyboardSelect
		.find( mw.format(
			'[value="$1"]',
			mw.storage.get( 'ext.gadget.translation-editor.keyboard-group' ) || 'latin'
		) )
		.prop( 'selected', true )
		.end()
		.trigger( 'change' );
	
	gui.$keyboardChars.on( 'click', 'span', function () {
		var label = $( this ).text(),
			action = charActions[ label ],
			replace = ( action.type === 'replace' );
		
		if ( !$activeTextInput.is( ':disabled' ) ) {
			// jquery.wikiEditor.toolbar.js, doAction()
			$activeTextInput.textSelection(
				'encapsulateSelection',
				$.extend( {}, action.options, {
					replace: replace
				} )
			).trigger( 'keypress' ).trigger( 'input' );
		}
	} );
	
	gui.$summarybox = $( '<div>' )
		.attr( 'id', 'transl-edit-summary' )
		.append(
			$( '<label>' )
				.attr( 'for', 'transl-summary' )
				.text( mw.msg( 'transl-summary-desc' ) ),
			' ',
			gui.$summaryPreview = $( '<i>' )
				.text( mw.msg( 'transl-summary-preview', '' ) ),
			' ',
			$( '<small>' ).append(
				gui.$summaryEdit = $( '<a>' )
					.attr( {
						href: '#',
						title: mw.msg( 'transl-summary-tooltip' )
					} )
					.text( mw.msg( 'transl-summary-edit' ) )
					.on( 'click', function () {
						gui.$summaryInput.show().focus();
						$( this ).hide();
						return false;
					} )
			),
			$( '<br>' ),
			gui.$summaryInput = $( '<input>' )
				.attr( {
					type: 'text',
					size: 100,
					id: 'transl-summary',
					placeholder: mw.msg( 'transl-summary-placeholder' )
				} )
				.hide()
		);
	
	gui.$summaryInput.on( 'input', mw.util.debounce( SUMMARY_PREVIEW_DELAY, function ( evt ) {
		if ( gui.$summaryInput.data( 'parseRequest' ) ) {
			gui.$summaryInput.data( 'parseRequest' ).abort();
		}
		
		gui.$summaryInput.data( 'parseRequest', previewSummary( gui ) );
	} ) );
	
	gui.$summaryInput.on( 'input', function ( evt ) {
		if ( gui.$summaryInput.data( 'parseRequest' ) ) {
			gui.$summaryInput.data( 'parseRequest' ).abort();
		}
	} );
	
	gui.$editbox = $( '<div>' )
		.attr( 'id', 'transl-editbox' )
		.append(
			$( '<div>' ).append(
				gui.$langLabel = $( '<p>' )
					.attr( 'id', 'transl-lang-label' ),
				gui.$langSelector,
				$( '<div>' )
					.attr( 'id', 'transl-selector-buttons' )
					.append(
						gui.$loadButton, gui.$saveButton
					)
			),
			$( '<div>' ).append(
				gui.$textInputs,
				gui.$textArea,
				$( '<span>' ).append(
					$( '<small>' ).append( gui.$specialCharsButton ),
					' ',
					$( '<small>' )
						.html( mw.msg( 'transl-rcf' ) )
						.attr( 'title', mw.msg( 'transl-rcf-tooltip' ) ),
					' ',
					$( '<small>' )
						.html( mw.msg( 'transl-report-error' ) )
						.attr( 'title', mw.msg( 'transl-report-tooltip' ) ),
					' ',
					$( '<small>' )
						.html( mw.msg( 'transl-help' ) )
						.attr( 'title', mw.msg( 'transl-help-tooltip' ) )
				)
			),
			$( '<div>' )
				.attr( 'id', 'transl-edit-buttons' )
				.append(
					gui.$apply, $( '<br>' ),
					gui.$diff, gui.$diffSpinner, $( '<br>' ),
					gui.$submit, gui.$submitSpinner, $( '<br>' ),
					gui.$clear ),
			gui.$summarybox,
			$( '<hr>' ),
			gui.$previewbox
		);
	
	gui.$editbox.on( 'focus', 'input[type="text"], textarea', function () {
		$activeTextInput = $( this );
	} );
	
	gui.$wrapper = $( '<div>' )
		.attr( 'id', 'transl-wrapper' )
		.hide()
		.append(
			gui.$editbox,
			$( '<small>' )
				.addClass( [ 'plainlinks', 'editpage-copywarn' ] )
				.html( mw.msg( 'transl-submit-legal' ) )
		)
		.insertAfter( $transl.parent() );
	
	langSelectorConfig = {
		fetch: function ( input, response, maxRows ) {
			input = unicodeToAscii( input.toLowerCase() ).trim();
			
			response( Object.keys( mw.libs.langData.lang2code ).filter( function ( lang ) {
				return (
					unicodeToAscii( lang.toLowerCase() ).indexOf( input ) === 0 &&
					forbiddenLanguageNames.indexOf( lang ) === -1
				);
			} ).concat( Object.keys( mw.libs.langData.aliases ).filter( function ( lang ) {
				return unicodeToAscii( lang.toLowerCase() ).indexOf( input ) === 0;
			} ).map( function ( lang ) {
				return mw.libs.langData.aliases[ lang ];
			} ) ).filter( function ( lang, i, self ) {
				return self.indexOf( lang ) === i;
			} ) );
		},
		result: {
			render: function ( suggestion, context ) {
				context.data.$container.find( '.suggestions-special' ).hide();
				this.text( suggestion );
			}
		},
		special: {
			render: function ( query, context ) {
				var $label, $result;
				
				if ( !this.children().length ) {
					this.append(
						$label = $( '<div>' ).addClass( 'special-label' ),
						$result = $( '<div>' ).addClass( 'special-query' )
					);
				} else {
					$label = this.find( '.special-label' );
					$result = this.find( '.special-query' );
				}
				
				query = query.toLowerCase();
				
				if (
					query in mw.libs.langData.code2lang &&
					forbiddenLanguageCodes.indexOf( query ) === -1
				) {
					$label.html( mw.msg( 'transl-lang-suggest-code', query ) );
					$result.text( mw.libs.langData.code2lang[ query ] );
					this.show();
				}
			}
		},
		highlightInput: true
	};
	
	gui.$langSelector.suggestions( $.extend( true, {}, langSelectorConfig, {
		result: {
			select: function ( $input ) {
				onLoadLang( gui, null );
			}
		},
		special: {
			select: function ( $input ) {
				var lang = this.find( '.special-query' ).text();
				$input.val( lang );
				onLoadLang( gui, null );
			}
		}
	} ) )
	.on( 'keypress', function ( evt ) {
		if ( evt.keyCode === 13 ) { // Enter
			gui.$loadButton.trigger( 'click' );
		}
	} )
	.on( 'paste cut drop', function ( evt ) {
		$( this ).trigger( 'keypress' );
	} );
	
	gui.$renameDialog = $( '<div>' ).append(
		gui.$renameDescription = $( '<p>' ),
		gui.$renameSelector = $( '<input>' )
			.attr( {
				id: 'transl-rename-selector',
				type: 'text',
				size: 22
			} )
			.suggestions( $.extend( true, {}, langSelectorConfig, {
				result: {
					select: function ( $input ) {
						gui.$renameErrorNotice.hide();
					}
				},
				special: {
					select: function ( $input ) {
						gui.$renameErrorNotice.hide();
						$input.val( this.find( '.special-query' ).text() );
					}
				}
			} ) )
			.on( 'keypress', function ( evt ) {
				var buttons;
				
				if ( evt.keyCode === 13 ) { // Enter
					buttons = gui.$renameDialog.dialog( 'option', 'buttons' );
					buttons[ 0 ].click();
				}
			} )
			.on( 'paste cut drop', function ( evt ) {
				$( this ).trigger( 'keypress' );
			} ),
		gui.$renameErrorNotice = $( '<p>' )
			.html( mw.msg( 'transl-rename-error' ) ).hide()
	).dialog( {
		resizable: false,
		minHeight: 150,
		modal: true,
		autoOpen: false,
		draggable: false,
		title: mw.msg( 'transl-rename-title' ),
		dialogClass: 'transl-dialog-no-close',
		buttons: [
			{
				text: mw.msg( 'transl-rename-apply' ),
				click: function () {
					var oldLang = gui.$renameDialog.data( 'activeLang' ),
						newLang = gui.$renameSelector.val().trim();
					
					if ( newLang === '' || newLang === oldLang ) {
						return;
					}
					
					if (
						forbiddenLanguageNames.indexOf( newLang ) !== -1 ||
						forbiddenTranslations.indexOf( newLang ) !== -1 ||
						newLang in gadget.drafts
					) {
						gui.$renameErrorNotice.show();
					} else {
						renameDraftLanguage( oldLang, newLang );
						gadget.drafts = sortByLanguage( gadget.drafts );
						gui.$preview.find( mw.format( '[data-lang="$1"]', oldLang ) ).remove();
						previewTranslation( gui, newLang );
						
						if ( [ oldLang, newLang ].indexOf( gadget.activeLang ) !== -1 ) {
							onLoadLang( gui, newLang );
						} else if ( !( newLang in mw.libs.langData.lang2code ) ) {
							mw.notify( mw.msg( 'transl-invalid-code', newLang ), { type: 'warn' } );
						}
						
						previewSummary( gui );
						resetSubmitButtons( gui );
						gui.$renameDialog.dialog( 'close' );
					}
				}
			},
			{
				text: mw.msg( 'transl-rename-cancel' ),
				click: function () {
					gui.$renameDialog.dialog( 'close' );
				}
			}
		],
		open: function ( event, ui ) {
			var lang = gui.$renameDialog.data( 'activeLang' );
			gui.$renameDescription.text( mw.msg( 'transl-rename-desc', lang ) );
			gui.$renameErrorNotice.hide();
			gui.$renameSelector.val( '' ).focus();
		}
	} );
	
	gui.$confirmSpinner = $.createSpinner().css( 'margin-right', '1em' ).hide();
	
	gui.$confirmDialog = $( '<p>' ).dialog( {
		resizable: false,
		minHeight: 150,
		modal: true,
		autoOpen: false,
		draggable: false,
		title: mw.msg( 'transl-prompt-title' ),
		dialogClass: 'transl-dialog-no-close',
		buttons: [
			{
				text: mw.msg( 'transl-save-save' ),
				click: function () {
					if ( gui.$confirmDialog.data( 'hasPendingRequest' ) ) {
						return;
					} else {
						gui.$confirmSpinner.show();
						gui.$confirmDialog.data( 'hasPendingRequest', true );
					}
					
					savePreferredLangRequest( gui )
						.fail( function ( code, data ) {
							mw.notify( api.getErrorMessage( data ), { type: 'warn' } );
						} )
						.always( function () {
							gui.$confirmDialog.dialog( 'close' );
						} );
				}
			},
			{
				text: mw.msg( 'transl-save-cancel' ),
				click: function () {
					if ( !gui.$confirmDialog.data( 'hasPendingRequest' ) ) {
						gui.$confirmDialog.dialog( 'close' );
					}
				}
			}
		],
		close: function ( event, ui ) {
			gui.$confirmDialog.empty().removeData( 'hasPendingRequest' );
			gui.$confirmSpinner.hide();
		}
	} );
	
	gui.$textInputs.find( 'input' ).on( 'keypress', function ( evt ) {
		if ( evt.key === 'Enter' && !evt.ctrlKey ) {
			onApplyChanges( gui, $defn );
		}
	} );
	
	gui.$confirmSpinner.prependTo( gui.$confirmDialog.parent().find( '.ui-dialog-buttonset' ) );
	
	$( document ).on( 'keypress', function ( evt ) {
		if (
			evt.key === 'Enter' && evt.ctrlKey &&
			gui.$wrapper.is( ':visible' ) && !gui.$submit.prop( 'disabled' )
		) {
			gui.$submit.trigger( 'click' );
			return false;
		}
	} );
	
	resetForms( gui );
	resetSubmitButtons( gui );
	makePreview( gui );
	previewSummary( gui );
	
	if ( !!gadget.preferredLang ) {
		gadget.activeLang = gadget.preferredLang;
		gui.$langSelector.val( gadget.activeLang );
		onLoadLang( gui, gadget.activeLang );
	}
	
	return gui;
}

module.exports = { init: init };