MediaWiki:Gadget-terminology.js

From translatewiki.net

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * Terminology gadget
 * 
 * This gadget adds functionality to keep terminology consistent for a language.
 * Read all about it on [[Project:Terminology gadget]].
 * 
 * @author Jon Harald Søby
 * @version 1.3.5 (2024-01-13)
 */
function initTerminology( initData ) {
	const initConditions = initData.initConditions,
		  user = mw.user,
		  username = mw.user.getName(),
		  api = new mw.Api(),
		  dir = $( 'html' ).attr( 'dir' );
	let terminologyLanguage = initData.terminologyLanguage,
		originalTerminologyLanguage = initData.originalTerminologyLanguage,
		termlist = initData.termlist,
		termlistIsInvalid = validateTermlist( termlist ),
		termlistParsed = initData.termlistParsed,
		termlistRevision = initData.termlistRevision,
		windowManager = new OO.ui.WindowManager();
	$( document.body ).append( windowManager.$element );

	if ( termlistIsInvalid ) {
		return mw.notify( termlistIsInvalid, { title: 'Terminology gadget', type: 'error' } );
	}

	mw.hook( 'mw.translate.translationView.stateChange').add( function( state ) {
		if ( originalTerminologyLanguage !== state.language ) {
			fetchTermlist( state.language ).then( function( newlangTermlist ) {
				terminologyLanguage = newlangTermlist[ 2 ];
				originalTerminologyLanguage = state.language;
				termlist = newlangTermlist[ 0 ];
				termlistRevision = newlangTermlist[ 1 ];
				return newlangTermlist;
			}).then( function( newlangTermlist ) {
				parseTermlist( termlist, originalTerminologyLanguage ).then( function( parsed ) {
					termlistParsed = parsed;
					return;
				});
			}).catch( function( err ) {
				console.warn( 'Terminology gadget error', err );
				return;
			});
		}
	});

	/**
	 * Return a list of terms without the aliases
	 * (helper function used by the edit dialog).
	 * 
	 * @returns {array}
	 */
	function termlistWithoutAliases() {
		let result = [];
		for ( const term in termlist ) {
			if ( !( '@alias' in termlist[ term ] ) ) {
				result.push( term );
			}
		}
		return result;
	}
	
	/**
	 * Find the correct term definition in the termlist, including if the
	 * input is an alias.
	 * 
	 * @param {string} word
	 * @returns {(object|boolean)}
	 */
	function termlistLookup( word ) {
		if ( word in termlist ) {
			if ( '@alias' in termlist[ word ] ) {
				return termlist[ termlist[ word ][ '@alias' ]];
			} else {
				return termlist[ word ];
			}
		} else {
			return false;
		}
	}
	
	/**
	 * Find the aliases of a term.
	 * 
	 * @param {string} word
	 * @returns {array}
	 */
	function findAliases( word ) {
		let aliasList = [];
		for ( const term in termlist ) {
			if ( termlist[ term ][ '@alias' ] && termlist[ term ][ '@alias' ] == word ) {
				aliasList.push( term );
			}
		}
		return aliasList;
	}
	
	/**
	 * Generate suggested alias values for a new term.
	 * 
	 * @param {string} word
	 * @returns {array}
	 */
	function generateSuggestions( word ) {
		let suggestions = [],
			root = word.slice( 0, -1 );
		if ( word.endsWith( 'ies' ) ) {
			word = word.replace( /ies$/, 'y' );
			root = word.slice( 0, -1 );
			suggestions.push( word );
		} else if ( word.endsWith( 's' ) && !word.endsWith( 'ss' ) ) {
			word = root;
			root = root.slice( 0, -1 );
			suggestions.push( word );
		}
		if ( /[sxz]$/.test( word ) ) {
			suggestions.push( word + 'es', word + 'ed', word + 'ing' );
		} else if ( /[^aeiou]y$/.test( word ) ) {
			suggestions.push( root + 'ies', root + 'ied', word + 'ing' );
		} else if ( word.slice( -1 ) === 'e' ) {
			suggestions.push( word + 's', word + 'd', root + 'ing' );
		} else {
			suggestions.push( word + 's', word + 'ed', word + 'ing' );
		}
		return suggestions;
	}

	/**
	 * Generate a button to go to Special:SearchTranslations
	 * when adding a new term.
	 * 
	 * @param {array} words
	 * @returns {jQuery}
	 */
	function searchTranslationsButton( words ) {
		if ( !words.length ) return false;
		let displayWord = words[ 0 ],
			searchUrl = mw.util.getUrl( 'Special:SearchTranslations', {
				'filter': 'translated',
				'language': originalTerminologyLanguage,
				'query': words.join( '|' )
			});
		let button = new OO.ui.ButtonWidget( {
			'label': mw.message( 'gadget-term-dialog-search-translations', displayWord ).text(),
			'icon': 'search',
			'target': '_blank',
			'href': searchUrl,
			'framed': false,
			'classes': [ 'gadget-term-searchtranslationsbutton' ]
		});
		return button;
	}

	/**
	 * Edit the termlist.
	 * 
	 * @param {object} [data] - Data about what should be changed
	 * @param {string} [action] - What type of edit should be performed
	 * @param {string} [addsummary=''] - Summary to add to the automatic summary
	 */
	function editTermlist( data, action, addsummary = '' ) {
		let newTermlist = termlist,
			word = data.word.trim(),
			oldAliases = findAliases( word ),
			termlistWord = termlist[ data.removeterm ] || termlist[ word ] || {},
			newTerm = {},
			today = new Date().toISOString().slice( 0, 10 ),
			summary;
		if ( action === 'delete' ) { // Delete an entire term + aliases
			delete newTermlist[ word ];
			for ( let alias of oldAliases ) {
				delete newTermlist[ alias ];
			}
			summary = 'Remove term "' + word + '": ' + addsummary;
		} else if ( action === 'resolveDiscussion' ) { // Mark a discussion as resolved
			delete newTermlist[ word ].discussion;
			if ( Object.keys( newTermlist[ word ] ).length === 0 || ( Object.keys( newTermlist[ word ] ).length === 1 && newTermlist[ word ][ '@metadata' ] ) ) {
				delete newTermlist[ word ];
			}
			summary = '[[Portal talk:' + terminologyLanguage + '#gadget-terminology-' + word + '|Discussion]] about "' + word + '" marked as resolved: ' + addsummary;
		} else if ( action === 'startDiscussion' ) { // Start a new discussion about a word
			if ( newTermlist[ word ] ) {
				newTermlist[ word ].discussion = true;
			} else {
				newTermlist[ word ] = { 'discussion': true };
			}
			summary = '[[Portal talk:' + terminologyLanguage + '#gadget-terminology-' + word + '|Discussion started]] about "' + word + '"';
		} else { // Default action, for 'edit' or 'add'
			if ( data.removeterm ) {
				delete newTermlist[ data.removeterm ];
			}
			if ( data.isAlias ) {
				newTermlist[ word ] = { '@alias': data.aliasTarget, '@metadata': { 'editors': [ username ], 'date_modified': today } };
			} else {
				if ( data.translation ) newTerm.translation = data.translation;
				if ( data.usage_notes ) newTerm.usage_notes = data.usage_notes;
				if ( termlistWord.discussion ) newTerm.discussion = true;
				if ( termlistWord[ '@metadata' ] ) {
					let editors = termlistWord[ '@metadata' ].editors;
					editors.push( username );
					newTerm[ '@metadata' ] = { 'editors': [ ...new Set( editors ) ], 'date_modified': today };
				} else {
					newTerm[ '@metadata' ] = { 'editors': [ username ], 'date_modified': today };
				}
				newTermlist[ word ] = newTerm;
				// If an alias that was present before is not present in the new
				// data, remove it from the termlist.
				for ( let oldAlias of oldAliases ) {
					if ( !( data.aliases.includes( oldAlias ) ) ) {
						delete newTermlist[ oldAlias ];
					}
				}
				// If an alias has been addded, add it to the termlist.
				for ( let newAlias of data.aliases ) {
					if ( !( oldAliases.includes( newAlias ) ) ) {
						newTermlist[ newAlias ] = { '@alias': word, '@metadata': { 'editors': [ username ], 'date_modified': today } };
					}
				}
			}
			summary = 'Edited the term "' + word + '"';
			if ( action === 'add' ) summary = 'Added the term "' + word + '"';
		}
		
		// Sort newTermlist by key (in order to have somewhat easier-to-read
		// terminology.json pages)
		
		// Credit for this goes to Mathias Bynens & wadezhan on Stack Overflow
		// https://stackoverflow.com/a/31102605/8196939
		// License: CC-by-SA 4.0
		const newTermlistSorted = Object.keys( newTermlist ).sort().reduce(
			( obj, key ) => {
				obj[ key ] = newTermlist[ key ];
				return obj;
			}, {});
		
		// At this point, newTermlist is finished and we can add it to the right
		// page via the API
		
		return api.postWithEditToken( {
			title: 'Portal:' + terminologyLanguage + '/terminology.json',
			action: 'edit',
			text: JSON.stringify( newTermlistSorted ),
			summary: summary,
			contentformat: 'application/json',
			contentmodel: 'json',
			baserevid: termlistRevision,
			tags: 'terminology'
		}).then( function( res ) {
			termlistRevision = res.edit.newrevid;
			return parseTermlist( newTermlist, originalTerminologyLanguage );
		}).then( function( res ) {
			termlistParsed = res;
			mw.notify( mw.message( 'postedit-confirmation-saved', user ).text(), { type: 'success' } );
			return res;
		}).catch( function( err ) {
			mw.notify( mw.message( 'edit-error-short', err ), { type: 'error' } );
			return err;
		});
	}
	
	/**
	 * Dialog for starting a new discussion about a term on the Portal talk page.
	 * 
	 * @param {string} word
	 * @todo Open discussion in new tab?
	 */
	function startDiscussion( word ) {
		function StartDiscussionDialog( config ) {
			StartDiscussionDialog.super.call( this, config );
		}
		OO.inheritClass( StartDiscussionDialog, OO.ui.ProcessDialog );
		
		StartDiscussionDialog.static.name = 'startDiscussionDialog';
		StartDiscussionDialog.static.title = mw.message( 'gadget-term-discussion-dialog-title', word ).text();
		StartDiscussionDialog.static.actions = [
			{ action: 'save', label: mw.message( 'gadget-term-discussion-save' ).text(), flags: [ 'primary', 'progressive' ], accessKey: mw.message( 'accesskey-save' ).text() },
			{ action: 'cancel', label: mw.message( 'gadget-term-dialog-cancel' ).text(), flags: 'safe', icon: 'close', invisibleLabel: true }
		];
		
		StartDiscussionDialog.prototype.initialize = function() {
			StartDiscussionDialog.super.prototype.initialize.apply( this, arguments );
			this.size = 'large';
			this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
			let discussionTopic = new OO.ui.TextInputWidget( {
				value: mw.message( 'gadget-term-discussion-topic', word ).text(),
				tabIndex: 1,
				id: 'discussion-topic'
			});
			let discussionPost = new OO.ui.MultilineTextInputWidget( {
				placeholder: mw.message( 'gadget-term-discussion-message-placeholder' ).text(),
				tabIndex: 2,
				rows: 5,
				autosize: true,
				maxRows: 8,
				id: 'discussion-post'
			});
			let fieldset = new OO.ui.FieldsetLayout();
			let discussionTopicField = new OO.ui.FieldLayout( discussionTopic, {
					label: mw.message( 'gadget-term-discussion-topic-label' ).text()
				}),
				discussionPostField = new OO.ui.FieldLayout( discussionPost, {
					label: mw.message( 'gadget-term-discussion-message', user ).text()
				});
			this.fields = [ discussionTopicField, discussionPostField ];
			fieldset.addItems( this.fields );
			this.content.$element.append( fieldset.$element );
			this.$body.append( this.content.$element );
		};
		StartDiscussionDialog.prototype.getActionProcess = function ( action ) {
			let dialog = this;
			if ( action == 'save' ) {
				return new OO.ui.Process( function () {
					dialog.pushPending();
					let data = {},
						termTemplate = '{{discuss term|' + word + '|language=' + terminologyLanguage + '}}\n';
					for ( let field of dialog.fields ) {
						let input = field.getField();
						data[ input.elementId ] = input.value;
					}
					api.postWithEditToken( {
						action: 'edit',
						title: 'Portal talk:' + terminologyLanguage,
						section: 'new',
						sectiontitle: data['discussion-topic'],
						text: termTemplate + data['discussion-post'].replace( /(\s*)~{4}\s*/g, '$1' ) + ' ~~' + '~~',
						tags: 'terminology',
						format: 'json',
						formatversion: 2
					}).then( function() {
						editTermlist( { word: word }, 'startDiscussion' ).done( function() {
							$( '.term-sourcemessage' ).each( function() {
								$( this ).replaceWith( processSourcemessage( $( this ) ) );
							});
						});
					}).then( function() {
						dialog.close();
						mw.notify(
							$( '<p>' ).append( mw.message( 'gadget-term-discussion-started-message', word, 'Portal talk:' + terminologyLanguage + '#gadget-term-' + word ).parseDom() ),
							{ title: mw.message( 'gadget-term-discussion-started-title' ).text(), type: 'success' }
						);
					}).catch( function( err ) {
						dialog.popPending();
						mw.notify( 'An error occured: ' + err, { type: 'error' } );
					});
				} );
			} else if ( action == 'cancel' ) {
				dialog.close();
			}
			return StartDiscussionDialog.super.prototype.getActionProcess.call( this, action );
		};
		
		let dialog = new StartDiscussionDialog();
		windowManager.addWindows( [ dialog ] );
		windowManager.openWindow( dialog );
	}
	
	/**
	 * Dialog for editing a term. Most of the magic happens here.
	 * 
	 * @param {string} word
	 * @param {boolean} [addTerm=false] - If the term doesn't exist in termlist already
	 */
	function editTermDialog( word, addTerm = false ) {
		let term = termlistLookup( word ) || false,
			showAdvanced = mw.storage.get( 'gadget-terminology-showadvanced' ) === 'true';
		if ( termlist[ word ] && termlist[ word ][ '@alias' ] ) word = termlist[ word ][ '@alias' ];
		function EditTermDialog( config ) {
			EditTermDialog.super.call( this, config );
		}
		OO.inheritClass( EditTermDialog, OO.ui.ProcessDialog );
		
		EditTermDialog.static.name = 'editTermDialog';
		EditTermDialog.static.title = addTerm ? mw.message( 'gadget-term-dialog-add-term' ).text() : mw.message( 'gadget-term-dialog-edit-term' ).text();
		
		let saveButton = new OO.ui.ActionWidget( {
				action: 'save',
				label: mw.message( 'gadget-term-dialog-save' ).text(),
				flags: [ 'primary', 'progressive' ],
				disabled: false,
				framed: true,
				accessKey: mw.message( 'accesskey-save' ).text()
			}),
			deleteButton = new OO.ui.ActionWidget( {
				action: 'delete',
				icon: 'trash',
				label: mw.message( 'gadget-term-dialog-delete' ).text(),
				flags: [ 'destructive' ],
				disabled: addTerm, // Disable the delete button if we are adding a new term
				classes: [ 'term-advanced' ],
				framed: true
			}),
			helpButton = new OO.ui.ActionWidget( {
				action: 'help',
				icon: 'help',
				label: mw.message( 'help' ).text(),
				invisibleLabel: true,
				framed: true,
				href: '/wiki/Special:MyLanguage/Project:Terminology_gadget',
				target: '_blank'
			});
		EditTermDialog.static.actions = [
			saveButton,
			{ action: 'cancel', label: mw.message( 'gadget-term-dialog-cancel' ).text(), flags: 'safe', icon: 'close', invisibleLabel: true },
			deleteButton,
			helpButton // Doesn't work properly, probably needs same magic later in getActionProcess
		];
		
		EditTermDialog.prototype.initialize = function () {
			EditTermDialog.super.prototype.initialize.apply( this, arguments );
			this.id = 'editTermDialog';
			this.size = 'larger';
			this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
			
			let editnotice = false;
			if (
				!mw.storage.get( 'gadget-terminology-showeditnotice-' + terminologyLanguage ) &&
				termlistParsed[ '@editnotice' ] &&
				termlistParsed[ '@editnotice' ].usage_notes
			) {
				editnotice = new OO.ui.MessageWidget( {
					type: 'notice',
					label: new OO.ui.HtmlSnippet( termlistParsed[ '@editnotice' ].usage_notes ),
					showClose: true // FIXME: Not available yet
				});
				editnotice.onCloseButtonClick = function( e ) {
					mw.storage.set( 'gadget-terminology-showeditnotice-' + terminologyLanguage, 'false' );
					mw.storage.setExpires( 'gadget-terminology-showeditnotice-' + terminologyLanguage, 8000000 ); // ~3 months
					editnotice.toggle( false );
				};
			}
			
			let termInput = new OO.ui.TagMultiselectWidget( {
				inputPosition: 'inline',
				allowArbitrary: true,
				selected: word ? [ word ].concat( findAliases( word ) ) : null,
				tabIndex: 1,
				dir: 'ltr',
				id: 'term-terminput'
			});
			let isAliasCheckbox = new OO.ui.CheckboxInputWidget( {
				value: 'isAlias',
				id: 'term-isAlias'
			});
			let aliasTargetDropdown = new OO.ui.DropdownInputWidget( {
				label: '',
				options: termlistWithoutAliases().map( x => ( { data: x, label: x } ) ),
				tabIndex: 3,
				id: 'term-aliasTarget'
			});
			let translation = new OO.ui.MultilineTextInputWidget( {
				placeholder: mw.message( 'gadget-term-dialog-translation-placeholder' ).text(),
				value: term.translation || '',
				tabIndex: 4,
				autosize: true,
				maxRows: 4,
				id: 'term-translation'
			});
			let usage_notes = new OO.ui.MultilineTextInputWidget( {
				placeholder: mw.message( 'gadget-term-dialog-usage-notes-placeholder' ).text(),
				value: term.usage_notes || '',
				tabIndex: 5,
				autosize: true,
				maxRows: 4,
				id: 'term-usage_notes'
			});
			if ( word ) termInput.findItemFromData( word ).$element.addClass( 'term-terminput-main' );
			let fieldset = new OO.ui.FieldsetLayout();
			let termInputField = new OO.ui.FieldLayout( termInput, {
					label: mw.message( 'gadget-term-dialog-english' ).text(),
					help: mw.message( 'gadget-term-dialog-english-help' ).text()
				}),
				aliasCheckboxField = new OO.ui.FieldLayout( isAliasCheckbox, {
					label: mw.message( 'gadget-term-dialog-is-alias' ).text(),
					classes: [ 'term-advanced' ]
				}),
				aliasTargetField = new OO.ui.FieldLayout( aliasTargetDropdown, {
					label: mw.message( 'gadget-term-dialog-alias-for' ).text(),
					help: mw.message( 'gadget-term-dialog-alias-for-help', user ).text(),
					classes: [ 'showWhenAliasSelected' ]
				}),
				translationField = new OO.ui.FieldLayout( translation, {
					label: mw.message( 'gadget-term-dialog-translation' ).text(),
					help: mw.message( 'gadget-term-dialog-translation-help', user ).text(),
					classes: [ 'hideWhenAliasSelected' ]
				}),
				usage_notesField = new OO.ui.FieldLayout( usage_notes, {
					label: mw.message( 'gadget-term-dialog-usage-notes' ).text(),
					help: mw.message( 'gadget-term-dialog-usage-notes-help', user ).text(),
					classes: [ 'hideWhenAliasSelected', 'term-advanced' ]
				}),
				emptyTranslationField = new OO.ui.FieldLayout( new OO.ui.MessageWidget( {
					type: 'error',
					inline: true,
					label: mw.message( 'gadget-term-dialog-translation-un-error' ).text()
				}), {
					align: 'inline',
					classes: [ 'term-empty-translation' ]
				});
			this.fields = [ termInputField, aliasCheckboxField, aliasTargetField, translationField, usage_notesField, emptyTranslationField ];
			fieldset.addItems( this.fields );
			if ( editnotice ) fieldset.addItems( [ editnotice ], 0 );
			this.content.$element.append( fieldset.$element );
			emptyTranslationField.$element.hide();
			
			let discussionButton = new OO.ui.ButtonWidget( {
				label: mw.message( 'gadget-term-dialog-start-discussion' ).text(),
				icon: 'speechBubbleAdd',
				disabled: !word, // Disabled by default if there is no word variable, enabled if there is
				data: { 'word': word }
			}).on( 'click', function() {
				dialog.close();
				startDiscussion( discussionButton.getData().word );
			} );
			
			let discussionLink = new OO.ui.MessageWidget( {
				type: 'warning',
				icon: 'speechBubbles',
				label: $( '<div>' ).append( mw.message( 'gadget-term-popup-discussion', 'Portal talk:' + terminologyLanguage + '#gadget-terminology-' + word ).parseDom() ),
				classes: [ 'term-add-margin' ]
			});
			
			// Check if the terms field is valid. It can not be empty, and the
			// terms in it can not already be defined in the termlist.
			// Display error messages for each case and disable the save button
			// while the error is not resolved.
			termInput.on( 'change', function() {
				let newAliasList = termInput.items.map( x => x.data.toLowerCase() ),
					existingAliases = [ word ].concat( findAliases( word ) ),
					inputId = termInput.getInputId(),
					suggestions = newAliasList.length ? generateSuggestions( newAliasList[ 0 ] ) : [],
					$suggestions = $( '<div>' ).addClass( 'term-suggestions' ).html( mw.message( 'gadget-term-dialog-suggested-aliases' ).text() + ' ' ).css( 'margin-top', '6px' ),
					searchTranslations = searchTranslationsButton( newAliasList ),
					errorMessage = new OO.ui.MessageWidget( {
						type: 'error',
						inline: true,
						classes: [ 'term-input-error', 'term-add-margin' ]
					});
				for ( const suggestion of suggestions ) {
					let suggestionElement = new OO.ui.TagItemWidget( {
						label: suggestion,
						fixed: true,
						draggable: false
					});
					suggestionElement.closeButton.$element.remove(); // Hacky, but it works
					suggestionElement.$element.on( 'click', function() {
						termInput.addTag( suggestion );
						$( this ).remove();
					});
					suggestionElement.$label.css( 'cursor', 'inherit' );
					// Override default padding that leaves space for the close button we removed
					suggestionElement.$element.css( { 'padding': '0 8px', 'cursor': 'pointer' } );
					if ( !newAliasList.includes( suggestion ) ) $suggestions.append( suggestionElement.$element );
				}
				
				// Add the suggestion element
				$( '.term-suggestions' ).remove();
				if ( suggestions.length !== 0 ) {
					termInput.$element.after( $suggestions );
				}
				
				// Add button to go to Special:SearchTranslations
				$( '.gadget-term-searchtranslationsbutton' ).remove();
				if ( searchTranslations ) {
					termInput.$element.parent().append( searchTranslations.$element );
				}
				
				// Add a dummy error element
				if ( $( '.term-input-error' ).length ) {
					$( '.term-input-error' ).empty().removeClass( 'term-add-margin' );
				} else {
					termInput.$element.after( $( '<div>' ).addClass( 'term-input-error' ) );
				}
				
				// Check for errors, replace dummy element if there are any
				if ( newAliasList.length === 0 && $( '#' + inputId ).length !== 0 ) {
					errorMessage.setLabel( mw.message( 'gadget-term-dialog-english-notempty' ).text() );
					$( '.term-input-error' ).replaceWith( errorMessage.$element );
					saveButton.setDisabled( true );
					discussionButton.setDisabled( true );
				} else {
					discussionButton.setData( { 'word': newAliasList[ 0 ] } );
					discussionButton.setDisabled( false );
					for ( const alias of newAliasList ) {
						if ( !( existingAliases.includes( alias ) ) && ( alias in termlist ) ) {
							errorMessage.setLabel( mw.message( 'gadget-term-dialog-english-error', alias ).text() );
							$( '.term-input-error' ).replaceWith( errorMessage.$element );
							saveButton.setDisabled( true );
							break;
						} else {
							$( '.term-input-error' ).empty().removeClass( 'term-add-margin' );
							saveButton.setDisabled( false );
						}
					}
				}
			});
			if ( termInput.items.length > 0 ) {
				termInput.emit( 'change' );
			}
			
			// Check if either the translation or the usage_notes field is filled
			// in. If neither is, disable the save button because there is nothing
			// productive to be done.
			function validationHelper() {
				if ( translation.getValue().trim().length + usage_notes.getValue().trim().length === 0 ) {
					emptyTranslationField.$element.slideDown();
					saveButton.setDisabled( true );
				} else {
					emptyTranslationField.$element.slideUp();
					saveButton.setDisabled( false );
				}
			}
			translation.on( 'change', validationHelper );
			usage_notes.on( 'change', validationHelper);
			
			this.content.$element.append( '<p>' + mw.message( 'gadget-term-dialog-footnote', user ).parse() + '</p>' );
			
			// Add a note about an existing discussion (at the top) if there is one.
			// If not, add a 'start a discussion' button at the bottom.
			// TODO: Add neither if dialog is initialized from a talk page.
			if ( term.discussion ) {
				this.content.$element.prepend( discussionLink.$element );
			} else {
				this.content.$element.append( $( '<div>' ).css( 'text-align', 'center' ).append( discussionButton.$element ) );	
			}
			
			// Add a metadata box about who has edited the term and when it was last edited.
			// TODO: Figure out how to parse the date to a language-specific human-readable format.
			if ( term ) {
				let editorList = term['@metadata'].editors.map( username => $( '<bdi>' ).append( $( '<a>' ).attr( 'href', mw.util.getUrl( 'User:' + username ) ).text( username ) ).prop( 'outerHTML' ) ),
					editors = new OO.ui.MessageWidget( {
						type: 'notice',
						icon: 'userContributions',
						label: new OO.ui.HtmlSnippet( mw.message( 'gadget-term-dialog-metadata', $( mw.language.listToText( editorList ) ), term['@metadata'].date_modified, editorList.length ).parseDom() ),
						classes: [ 'term-metadata', 'term-advanced', 'term-add-margin' ]
					});
				this.content.$element.append( editors.$element.toggle( showAdvanced ) );
			}
			aliasTargetField.$element.hide();
			if ( !addTerm ) aliasCheckboxField.$element.hide();
			if ( !showAdvanced ) {
				deleteButton.$element.hide();
				aliasCheckboxField.$element.hide();
				usage_notesField.$element.hide();
			}
			isAliasCheckbox.on( 'change', function() {
				if ( isAliasCheckbox.isSelected() ) {
					$( '.showWhenAliasSelected' ).slideDown();
					$( '.hideWhenAliasSelected' ).slideUp();
				} else {
					$( '.showWhenAliasSelected' ).slideUp();
					$( '.hideWhenAliasSelected' ).slideDown();
				}
			});
			
			let showAdvancedButton = new OO.ui.ToggleSwitchWidget({
				id: 'advancedToggle',
				value: showAdvanced
			}).on( 'change', function() {
				if ( showAdvancedButton.getValue() ) {
					// TODO: Alias checkbox behaviour is not quite as intended
					if ( addTerm ) aliasCheckboxField.$element.slideDown();
					$( '.term-advanced' ).slideDown();
					mw.storage.set( 'gadget-terminology-showadvanced', true );
				} else {
					$( '.term-advanced' ).slideUp();
					mw.storage.set( 'gadget-terminology-showadvanced', false );
				}
			});
			let showAdvancedOption = new OO.ui.HtmlSnippet(
					$( '<p>' )
						.addClass( 'term-show-advanced' )
						.text( mw.message( 'gadget-term-dialog-show-advanced' ).text() )
						.append( showAdvancedButton.$element )
				).content;
			this.$body.append( this.content.$element );
			this.$otherActions.append( showAdvancedOption ); // Kinda hacky, but it works.
		};
		
		EditTermDialog.prototype.getActionProcess = function( action ) {
			let dialog = this;
			if ( action === 'save' ) {
				return new OO.ui.Process( function () {
				// Probably not using this correctly, but it works, so... Maybe the OO.ui.Process can be omitted entirely.
					dialog.pushPending();
					let submits = {};
					for ( let field of dialog.fields ) {
						let input = field.getField();
						switch ( input.elementId ) {
							case 'term-terminput':
								let inputTerms = input.items.map( x => x.data.toLowerCase() );
								if ( inputTerms.includes( word ) ) {
									submits.word = word;
									submits.aliases = inputTerms.filter( x => x !== word );
								} else {
									submits.word = inputTerms[ 0 ];
									submits.aliases = inputTerms.slice( 1 );
									submits.removeterm = word;
								}
								break;
							case 'term-isAlias':
								submits.isAlias = input.selected;
								break;
							case 'term-aliasTarget':
								submits.aliasTarget = input.value;
								break;
							case 'term-translation':
								submits.translation = input.value.trim();
								break;
							case 'term-usage_notes':
								submits.usage_notes = input.value.trim();
								break;
						}
					}
					if ( ( submits.translation.length + submits.usage_notes.length === 0 ) && !submits.isAlias ) {
						dialog.close();
					} else {
						editTermlist( submits, addTerm ? 'add' : 'edit' ).done( function() {
							if ( initConditions === 'Special:Translate' ) {
								$( '.term-sourcemessage' ).each( function() {
									$( this ).replaceWith( processSourcemessage( $( this ) ) );
								});
								dialog.close();
							} else {
								location.reload();
							}
						});
					}
				} );
			} else if ( action === 'delete' ) {
				dialog.pushPending();
				OO.ui.prompt( mw.message( 'gadget-term-dialog-delete-reason', user ).text() ).done( function( summary ) {
					if ( summary !== null ) {
						editTermlist( { word: word }, 'delete', summary ).done( function() {
							$( '.term-sourcemessage' ).each( function() {
								$( this ).replaceWith( processSourcemessage( $( this ) ) );
							});
						});
						dialog.close();
					} else {
						dialog.popPending();
					}
				});
			} else if ( action === 'help' ) {
				window.open( '/wiki/Special:MyLanguage/Project:Terminology_gadget', '_blank' );
			} else if ( action === 'cancel' ) {
				dialog.close();
			}
			return EditTermDialog.super.prototype.getActionProcess.call( this, action );
		};
		
		let dialog = new EditTermDialog();
		windowManager.addWindows( [ dialog ] );
		windowManager.openWindow( dialog );
	}
	
	/**
	 * Build the popup for words that have a definition.
	 * 
	 * @param {string} word
	 * @param {object} term
	 * @param {jQuery} element - The <span> element to amend
	 * @param {boolean} [onTalkPage=false] - Are we on a talk page?
	 * @returns {jQuery}
	 */
	function popupBuilder( word, term, element, onTalkPage = false ) {
		let popup, popupMenu, discussionBox, popupClose,
			shouldPopupClose = true,
			$popupContent = $( '<div>' ).append( '<dl>' ),
			wordId = mw.util.escapeIdForAttribute( word );
		if ( termlist[ word ] && termlist[ word ][ '@alias' ] ) word = termlist[ word ][ '@alias' ];
		discussionBox = new OO.ui.MessageWidget( {
			type: 'warning',
			icon: 'speechBubbles',
			label: $( '<div>' ).append( mw.message( 'gadget-term-popup-discussion', 'Portal talk:' + terminologyLanguage + '#gadget-terminology-' + wordId ).parseDom() )
		});
		popupMenu = new OO.ui.ButtonMenuSelectWidget( {
			$overlay: true,
			label: 'Menu',
			invisibleLabel: true,
			framed: false,
			icon: 'menu',
			classes: [ 'term-popup-menu' ],
			menu: {
				items: [
					new OO.ui.MenuOptionWidget({
						data: 'edit',
						label: mw.message( 'gadget-term-popup-edit-term' ).text(),
						icon: 'edit'
					}),
					new OO.ui.MenuOptionWidget({
						data: 'discuss',
						label: mw.message( 'gadget-term-popup-discuss-term' ).text(),
						icon: 'speechBubbleAdd'
					})
				]
			}
		});
		popup = new OO.ui.PopupWidget( {
			$content: onTalkPage ? $popupContent : $.merge( $( popupMenu.$element ), $popupContent ),
			padded: true,
			position: ( initConditions === 'terminology.json' ) ? 'before' : 'above',
			$container: $( '#bodyContent' ),
			autoClose: true,
			width: 400
		});
		if ( term.translation ) {
			$popupContent.append( $( '<dt>' ).text( mw.message( 'gadget-term-popup-translation' ).text() ) );
			$popupContent.append( $( '<dd>' ).html( termlistParsed[ word ].translation ) );
			element.addClass( 'term-translation' );
		}
		if ( term.usage_notes ) {
			$popupContent.append( $( '<dt>' ).text( mw.message( 'gadget-term-popup-usage-notes' ).text() ) );
			$popupContent.append( $( '<dd>' ).html( termlistParsed[ word ].usage_notes ) );
			element.addClass( 'term-usage_notes' );
		}
		if ( term.discussion && !onTalkPage ) {
			$popupContent.append( discussionBox.$element.css( 'clear', 'both' ) );
			element.addClass( 'term-discussion' );
		}
		
		// Don't hide the popup after the menu button has been clicked. This is
		// necessary because the menu opens a floating div that is not a child
		// of the popup element, so hovering it would cause the popup to close
		// while the menu stays open.
		// TODO: Could need some tweaking though, because if you click the menu
		// button  and then click somewhere outside of it, the popup closes, but
		// the next time the popup opens, it will stay open.
		popupMenu.on( 'click', () => shouldPopupClose = !shouldPopupClose );
		popupMenu.getMenu().on( 'choose', function( menuOption) {
			switch ( menuOption.getData() ) {
				case 'edit':
					editTermDialog( word );
					break;
				case 'discuss':
					startDiscussion( word );
					break;
			}
		});
		// The next line is necessary because the popup is child of the source
		// message div, which always is English and has dir=ltr
		popup.$element.attr( 'dir', $( 'html' ).attr( 'dir' ) );
		element.append( popup.$element ).on( 'mouseover', function() {
			popup.toggle( true );
			clearTimeout( popupClose );
		}).on( 'mouseout', function() {
			popupClose = setTimeout( function() {
				popup.toggle( !shouldPopupClose );
			}, 200);
		});
		return element;
	}
	
	/**
	 * Process a .sourcemessage text to add the necessary spans and elements
	 * to it.
	 * 
	 * @param {jQuery} element
	 * @returns {jQuery} element
	 */
	function processSourcemessage( element ) {
		let $sourcemessage = element;
		if ( !$sourcemessage.data( 'original' ) ) $sourcemessage.data( 'original', $sourcemessage.text() ).addClass( 'term-sourcemessage' );
		
		// Sort the termlist in descending order by key length.
		// This gives us the longest keys first, which feeds into the
		// regex and ensures that compound terms (i.e. terms with spaces
		// in them) are treated first.
		let termlistDescending = Object.keys( termlist ).sort( function( a, b ) {
				return b.length - a.length;
			});
		// Human-friendly explanation of the following regex:
		// * Find all occurences of keys from termlistDescending
		// * That are surrounded by word boundaries \b, in order to avoid
		//   marking up substrings.
		// * That are not encapsulated in <tags>
		// * Finally, find all other Latin-script words (\p{L})
		let regex = new RegExp( '(?<!&lt;|&lt;\/|&)\\b(' + termlistDescending.map( x => mw.util.escapeRegExp( x ).replace( '\\-', '-' ) ).concat(['']).join('|') + '\\p\{L\}+)\\b(?!&gt;|;)', 'gui' );
		let text = $sourcemessage.data( 'original' ) || '';
		text = mw.html.escape( text );
		text = text.replaceAll( regex, function( x ) {
			return '<span data-term="' + x.toLowerCase() + '">' + x + '</span>';
		});
		$sourcemessage.html( text );
		
		$sourcemessage.children( 'span[data-term]' ).each( function() {
			let adderShow;
			let $this = $( this );
			const word = $( this ).attr( 'data-term' ),
				  term = termlistLookup( word );
			if ( term ) {
				let popup = popupBuilder( word, term, $this );
				$this.replaceWith( popup );
			} else {
				const addButton = new OO.ui.ButtonWidget( {
					label: mw.message( 'gadget-term-dialog-add-term-adder' ).text(),
					title: mw.message( 'gadget-term-dialog-add-term-adder' ).text(),
					icon: 'add',
					invisibleLabel: true,
					framed: false,
					flags: [ 'progressive' ]
				}).on( 'click', () => editTermDialog( word, true ) );
				let adder = new OO.ui.PopupWidget( {
					$content: addButton.$element.css( { 'margin-left': '0', 'margin-right': '0' } ),
					$container: $( '#bodyContent' ),
					position: 'after',
					padded: false,
					width: null,
					anchor: false,
					classes: [ 'term-adder' ]
				});
				$this.append( adder.$element );
				let showAdder = false;
				$this.on( 'mouseover', function() {
					showAdder = true;
					adderShow = setTimeout( function() {
						adder.toggle( showAdder );
					}, 300 );
				}).on( 'mouseout', function() {
					clearTimeout( adderShow );
					showAdder = false;
					adderShow = setTimeout( function() {
						adder.toggle( showAdder );
					}, 1);
				});
			}
		});
		return $sourcemessage;
	}
	
	if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'Translate' ) {
		mw.hook( 'mw.translate.editor.afterEditorShown' ).add( function( data ) {
			let $this = $( data ).find( '.sourcemessage:not(.term-sourcemessage)' );
			$this.replaceWith( processSourcemessage( $this ) );
		});
	} else if ( $( '.gadget-term-discuss' ).length ) {
		$( '.gadget-term-discuss' ).each( function() {
			let $template = $( this );
			$template.removeAttr( 'style' );
			if ( $template.attr( 'data-language' ) !== terminologyLanguage ) {
				$template.addClass( 'gadget-term-error' ).html( mw.message( 'gadget-term-discuss-language-mismatch', 'language', user ).parse() );
			} else {
				let word, term;
				let templateMenu = new OO.ui.ButtonMenuSelectWidget( {
						icon: 'menu',
						label: 'Menu',
						invisibleLabel: true,
						framed: false,
						classes: [ 'gadget-term-discuss-menu' ],
						menu: {
							horizontalPosition: 'end',
							items: [
								new OO.ui.MenuOptionWidget({
									data: 'edit',
									label: mw.message( 'gadget-term-popup-edit-term' ).text(),
									icon: 'edit'
								}),
								new OO.ui.MenuOptionWidget({
									data: 'reopen',
									label: mw.message( 'gadget-term-discuss-reopen' ).text(),
									icon: 'speechBubble'
								})
							]
						}
					});
				templateMenu.getMenu().on( 'choose', function( menuOption) {
					switch ( menuOption.getData() ) {
						case 'edit':
							editTermDialog( word, true );
							break;
						case 'discuss':
							startDiscussion( word );
							break;
					}
				});
				$template.find( '[data-term]' ).each( function() {
					word = $( this ).attr( 'data-term' );
					term = termlistLookup( word );
					if ( term ) {
						if ( term.translation || term.usage_notes ) popupBuilder( word, term, $( this ), true );
						if ( term.discussion ) {
							$template.addClass( 'gadget-term-discuss-open' );
						} else {
							$template.append( templateMenu.$element );
							$template.addClass( 'gadget-term-discuss-resolved' );
							$template.find( '.gadget-term-status' ).html( mw.message( 'gadget-term-discuss-resolved' ).parse() );
							$template.find( '.gadget-term-controls' ).hide();
						}
					} else {
						const adder = $( '<div>' ).addClass( 'term-adder' ).text( '+' ).on( 'click', () => editTermDialog( word, true ) );
						$( this ).append( adder );
						$template.append( templateMenu.$element );
						$template.addClass( 'gadget-term-discuss-resolved' );
						$template.find( '.gadget-term-status' ).html( mw.message( 'gadget-term-discuss-resolved' ).parse() );
						$template.find( '.gadget-term-controls' ).hide();
					}
				});
				$template.find( '.gadget-term-editlink' ).on( 'click', function( e ) {
					e.preventDefault();
					editTermDialog( word );
				});
				$template.find( '.gadget-term-resolvelink' ).on( 'click', function( e ) {
					e.preventDefault();
					OO.ui.prompt( mw.message( 'gadget-term-discuss-summary' ).text() ).done( function( summary ) {
						if ( summary !== null ) {
							editTermlist( { word: word }, 'resolveDiscussion', summary );
							mw.notify( mw.message( 'gadget-term-discuss-marked-resolved', summary ).text(), { title: word, type: 'success' });
						}
					});
				});
			}
		});
	} else if ( mw.config.get( 'wgPageName' ).endsWith( '/terminology.json' ) ) {
		$( 'table.mw-json' ).hide();
		let $prejson = $( '<div>' ).addClass( 'gadget-term-json mw-body-content mw-content-' + dir ),
			$table = $( '<table>' ),
			toggleButton = new OO.ui.ButtonWidget( {
				label: mw.message( 'gadget-term-json-showhide' ).text(),
				icon: 'expand',
				flags: [ 'progressive' ]
			}).on( 'click', function() {
				$( 'table.mw-json' ).toggle();
				toggleButton.setIcon( $( 'table.mw-json' ).is( ':visible' ) ? 'collapse' : 'expand' );
			}),
			redirectMessage = new OO.ui.MessageWidget( {
				label: new OO.ui.HtmlSnippet( mw.message( 'gadget-term-json-redirect', originalTerminologyLanguage, terminologyLanguage ).parse() ),
				icon: 'articleRedirect',
				type: 'warning'
			}),
			$introMessage = $( '<p>' ).append( mw.message( 'gadget-term-json-intro', user ).parseDom() ),
			addTermButton = new OO.ui.ButtonWidget( {
				label: mw.message( 'gadget-term-dialog-add-term' ).text(),
				flags: [ 'progressive', 'primary' ],
				icon: 'add'
			}).on( 'click', function() {
				editTermDialog( '', true ); // '' because no word is specified in this context
			});
		redirectMessage.$icon.css( 'background-position', '50%' );
		if ( termlistWithoutAliases().length === 0 ) {
			$table.append( '<tr>' ).append( '<td>' ).addClass( 'nodata' ).css( 'text-align', 'center' ).html( mw.message( 'gadget-term-json-noterms' ).parse() );
		} else {
			for ( const word of termlistWithoutAliases() ) {
				let $tr = $( '<tr>' ),
					$td1 = $( '<td>' ).addClass( 'gadget-term-term' ),
					$td2 = $( '<td>' ),
					$termspan = $( '<span>' ).attr( 'data-term', word ).text( word ),
					term = termlist[ word ];
				$td1.append( popupBuilder( word, termlistLookup( word ), $termspan ) ).append( '<br />' );
				$td1.append( findAliases( word ).join( mw.message( 'comma-separator' ).text() ) );
				if ( !term.translation && !term.usage_notes ) {
					$td2.append( $( '<p>' ).addClass( 'nodata' ).html( mw.message( 'gadget-term-json-nodata' ).parse() ) );
				}
				if ( term.translation ) $td2.append( $( '<p>' ).html( '<b>' + mw.message( 'gadget-term-popup-translation' ).text() + '</b>' + mw.message( 'word-separator' ).text() + termlistParsed[ word ].translation ) );
				if ( term.usage_notes ) $td2.append( $( '<p>' ).html( '<b>' + mw.message( 'gadget-term-popup-usage-notes' ).text() + '</b>' + mw.message( 'word-separator' ).text() + termlistParsed[ word ].usage_notes ) );
				if ( term[ '@metadata' ] ) {
					let editorList = term['@metadata'].editors.map( username => $( '<bdi>' ).append( $( '<a>' ).attr( 'href', mw.util.getUrl( 'User:' + username ) ).text( username ) ).prop( 'outerHTML' ) );
					$td2.append( $( '<p>' ).addClass( 'metadata' ).append( mw.message( 'gadget-term-dialog-metadata', $( mw.language.listToText( editorList ) ), term['@metadata'].date_modified, editorList.length ).parseDom() ) );
				}
				$tr.append( $td1 );
				$tr.append( $td2 );
				$table.append( $tr );
			}
		}
		$prejson.append( $introMessage );
		if ( terminologyLanguage !== originalTerminologyLanguage ) $prejson.append( redirectMessage.$element );
		$prejson.append( addTermButton.$element );
		$prejson.append( $table );
		$prejson.append( toggleButton.$element.css( { 'text-align': 'center', 'display': 'block' } ) );
		$( '#mw-content-text' ).first().before( $prejson );
	}
}

/**
 * Fetch the termlist page from the API.
 * 
 * It returns an array with three members:
 * 0 - the actual termlist
 * 1 - the revision ID of the termlist (to help detect edit conflicts)
 * 2 - the language code of the termlist (may be different from the language
 *     code in is translating into, in case of redirects))
 * 
 * @param {string} language
 * @returns {array}
 */
function fetchTermlist( language ) {
	return new mw.Api().get( {
		action: 'query',
		prop: 'revisions',
		titles: 'Portal:' + language + '/terminology.json',
		rvprop: 'content|ids',
		rvslots: 'main',
		format: 'json',
		maxage: 0,
		smaxage: 0,
		requestid: Date.now()
	}).then( function( output ) {
		const pageid = Object.keys( output.query.pages )[ 0 ];
		if ( pageid === '-1' ) {
			return [ {}, 0, language ];
		} else {
			const currentrev = output.query.pages[ pageid ].revisions[ 0 ].revid,
				  content = JSON.parse( output.query.pages[ pageid ].revisions[ 0 ].slots.main[ '*' ] );
			if ( Object.keys( content ).includes( '@redirect' ) ) {
				return fetchTermlist( content[ '@redirect' ] );
			} else {
				return [ content, currentrev, language ];
			}
		}
	}).fail( function( err ) {
		throw 'Something went wrong when fetching terminology.json';
	});
}

/**
 * Validate the termlist.
 * 
 * Check that:
 * * It doesn't contain any empty keys
 * * All values have at least one valid (sub)key
 * * All terms that are aliases point to an existing term
 * * All translations and/or usage_notes have the required metadata field.
 * @param {Object} termlist
 * @returns {(boolean|string)} Either false (for no error) or an error string
 */
function validateTermlist( termlist ) {
	for ( const [key, value] of Object.entries( termlist ) ) {
		if ( key.length === 0 ) {
			return 'There is an empty key in the termlist';
		}
		if ( !( value.translation || value.usage_notes || value.discussion || value[ '@alias' ] ) ) {
			return 'The key "$1" does not have any of the required fields.'
				.replace( '$1', key );
		}
		if ( ( value.translation || value.usage_notes ) && !value[ '@metadata' ] ) {
			return 'The definition for "$1" is missing the required metadata field.'
				.replace( '$1', key );
		}
		if ( value[ '@alias' ] && !termlist.hasOwnProperty( value[ '@alias' ] ) ) {
			return 'The key "$1" is an alias for "$2", which isn\'t defined.'
				.replace( '$1', key )
				.replace( '$2', value[ '@alias' ] );
		}
	}
	return false;
}

/**
 * Parse the termlist.
 * @param {object} termlist
 * @param {string} originalTerminologyLanguage
 * @returns {object}
 */
function parseTermlist( termlist, originalTerminologyLanguage ) {
	let termlistToParse = [];
	for ( const term in termlist ) {
		for ( const deftype in termlist[ term ] ) {
			if ( [ 'translation', 'usage_notes' ].includes( deftype ) ) {
				termlistToParse.push( '((((' + term + '|' + deftype + '))))\n\n' + termlist[ term ][ deftype ].replaceAll( '$VARIANT', originalTerminologyLanguage ) );
			}
		}
	}
	if ( termlistToParse.length === 0 ) {
		return Promise.resolve( {} );
	} else {
		return new mw.Api().parse( termlistToParse.join('\n\n␞\n\n'), {
			wrapoutputclass: '',
			disablelimitreport: true,
			title: mw.config.get( 'wgPageName' )
		} ).then( function( owndata ) {
			let owndatasplit = owndata.split( '<p>␞\n</p>' ),
				parsed = {};
			for ( const term of owndatasplit ) {
				const regex = /<p>\(\(\(\((.+?)\|(.+?)\)\)\)\)\r?\n<\/p>\n?/,
					  matches = term.match( regex ),
					  word = matches[ 1 ],
					  deftype = matches[ 2 ],
					  parseresult = term.replace( regex, '' );
				if ( !parsed[ word ] ) parsed[ word ] = {};
				parsed[ word ][ deftype ] = parseresult;
			}
			return parsed;
		});
	}
}

(function() {
	const pageName = mw.config.get( 'wgPageName' );
	let language, initConditions, originalLanguage;
	if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'Translate' ) {
		initConditions = 'Special:Translate';
		language = $( '.tux-messagelist' ).attr( 'data-targetlangcode' );
	} else if ( ( $( '.gadget-term-discuss' ).length ) ) {
		initConditions = 'PortalTalk';
		language = $( '.gadget-term-discuss' ).attr( 'data-language' );
	} else if ( ( /^Portal:[A-Za-z]{2,3}(-[A-Za-z\d]+)*\/terminology\.json$/u.test( pageName ) ) &&
				( mw.config.get( 'wgAction' ) === 'view' ) &&
				( !mw.config.get( 'wgDiffNewId' ) ) ) {
		initConditions = 'terminology.json';
		language = pageName.replace( /^Portal:(.*)\/terminology\.json/, '$1' ).toLowerCase();
	}
	originalLanguage = language;
	if ( !initConditions ) return;
	
	// Set up conditions
	function startupHelper( termlist, loadMessages ) {
		let initData = {};
		initData.initConditions = initConditions;
		initData.termlist = termlist[ 0 ];
		initData.termlistRevision = termlist[ 1 ];
		initData.terminologyLanguage = termlist[ 2 ];
		initData.originalTerminologyLanguage = originalLanguage;
		return parseTermlist( initData.termlist, originalLanguage ).then( function( output ) {
			initData.termlistParsed = output;
			return initData;
		}).catch( function( err ) {
			console.warn( 'Terminology gadget error', err );
			return;
		});
	}
	
	let termlist = fetchTermlist( language );
	let loadMessages = new mw.Api().loadMessagesIfMissing( [
		'accesskey-save', // reuse of generic message
		'comma-separator', // reuse of generic message
		'edit-error-short', // reuse of generic message
		'help', // reuse of generic message
		'postedit-confirmation-saved', // reuse of generic message
		'word-separator', // reuse of generic message
		'gadget-term-dialog-add-term',
		'gadget-term-dialog-add-term-adder',
		'gadget-term-dialog-edit-term',
		'gadget-term-dialog-save',
		'gadget-term-dialog-cancel',
		'gadget-term-dialog-delete',
		'gadget-term-dialog-english',
		'gadget-term-dialog-english-help',
		'gadget-term-dialog-english-error',
		'gadget-term-dialog-english-notempty',
		'gadget-term-dialog-suggested-aliases',
		'gadget-term-dialog-search-translations',
		'gadget-term-dialog-is-alias',
		'gadget-term-dialog-alias-for',
		'gadget-term-dialog-alias-for-help',
		'gadget-term-dialog-translation',
		'gadget-term-dialog-translation-help',
		'gadget-term-dialog-translation-placeholder',
		'gadget-term-dialog-usage-notes',
		'gadget-term-dialog-usage-notes-help',
		'gadget-term-dialog-usage-notes-placeholder',
		'gadget-term-dialog-translation-un-error',
		'gadget-term-dialog-metadata',
		'gadget-term-dialog-footnote',
		'gadget-term-dialog-start-discussion',
		'gadget-term-dialog-show-advanced',
		'gadget-term-dialog-delete-reason',
		'gadget-term-popup-edit-term',
		'gadget-term-popup-discuss-term',
		'gadget-term-popup-translation',
		'gadget-term-popup-usage-notes',
		'gadget-term-popup-discussion',
		'gadget-term-discussion-dialog-title',
		'gadget-term-discussion-topic-label',
		'gadget-term-discussion-topic',
		'gadget-term-discussion-message',
		'gadget-term-discussion-message-placeholder',
		'gadget-term-discussion-save',
		'gadget-term-discussion-started-title',
		'gadget-term-discussion-started-message',
		'gadget-term-started-title',
		'gadget-term-started-message',
		'gadget-term-discuss-language-mismatch',
		'gadget-term-discuss-summary',
		'gadget-term-discuss-resolved',
		'gadget-term-discuss-marked-resolved',
		'gadget-term-discuss-reopen',
		'gadget-term-json-intro',
		'gadget-term-json-redirect',
		'gadget-term-json-nodata',
		'gadget-term-json-noterms',
		'gadget-term-json-showhide',
		// The following aren't used by the script directly, but by {{discuss term}}
		'gadget-term-discuss-nogadget',
		'gadget-term-discuss-open',
		'gadget-term-discuss-edit',
		'gadget-term-discuss-mark-resolved',
		'gadget-term-editnotice',
		// The following aren't used by the script directly, but by {{portal}}
		'gadget-term-jsonlink-label',
		'gadget-term-jsonlink-title'
	]);

	$.when( termlist, loadMessages )
		.then( startupHelper )
		.then( initTerminology )
		.fail( function( err ) {
			console.log( 'Terminology gadget error', err );
		});
})();