Note: After saving, changes may not occur immediately. Click here to learn how to bypass your browser's cache.
  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Cmd-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (Cmd-Shift-R on a Mac)
  • Internet Explorer: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Clear the cache in Tools → Preferences

For details and instructions about other browsers, see Wikipedia:Bypass your cache.

/**
 * Simple maintenance tools
 */

/* eslint-disable camelcase, no-var */

'use strict';

// IIFE used when including as a user script (to allow debug or config)
// Default gadget use will get an IIFE wrapper as well
( function ( $, mw, OO ) {

	window.inductiveload = window.inductiveload || {}; // use window for ResourceLoader compatibility

	if ( inductiveload.maintain ) {
		return; // already initialised, don't overwrite
	}

	inductiveload.maintain = {
		transforms: {}
	};

	/* -------------------------------------------------- */

	/*
	 * Generic wrapper around MW.APi.get
	 *
	 * Returns a deferred. Results are resolve()'d after passing through the
	 * given filter functions.
	 */
	var mw_get_deferred = function ( params, result_filter, failure ) {
		var deferred = $.Deferred();

		new mw.Api().get( params )
			.done( function ( data ) {
				deferred.resolve( result_filter( data ) );
			} )
			.fail( function () {
				deferred.resolve( failure() );
			} );

		return deferred;
	};

	var emptyArrayFunc = function () {
		return [];
	};
	var emptyStringFunc = function () {
		return '';
	};

	/*
	 * Get pages with prefix
	 */
	var get_page_suggestions = function ( input, namespaces ) {

		var params = {
			format: 'json',
			formatversion: 2,
			action: 'query',
			list: 'prefixsearch',
			pslimit: 15,
			pssearch: input
		};
		if ( namespaces && namespaces.length ) {
			params.apnamespace = namespaces.join( '|' );
		}

		return mw_get_deferred( params,
			function ( data ) {
				return data.query.prefixsearch;
			},
			emptyArrayFunc );
	};

	var get_last_edited = function ( user, namespaces ) {

		var params = {
			format: 'json',
			formatversion: 2,
			action: 'query',
			list: 'usercontribs',
			uclimit: 15,
			ucuser: user
		};
		if ( namespaces && namespaces.length ) {
			params.ucnamespace = namespaces.join( '|' );
		}

		return mw_get_deferred( params,
			function ( data ) {
				return data.query.usercontribs;
			},
			emptyArrayFunc );
	};

	var get_page_wikitext = function ( title ) {
		var params = {
			action: 'query',
			format: 'json',
			formatversion: 2,
			prop: 'revisions',
			rvslots: 'main',
			rvprop: 'content',
			titles: title
		};

		return mw_get_deferred( params,
			function ( data ) {
				return data.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
			},
			emptyStringFunc
		);
	};

	var get_last_contribs = function ( user, limit ) {
		var params = {
			action: 'query',
			format: 'json',
			formatversion: 2,
			list: 'usercontribs',
			ucuser: user,
			limit: limit
		};

		return mw_get_deferred( params,
			function ( data ) {
				return data.query.usercontribs;
			},
			emptyArrayFunc
		);
	};

	const getPageWikitext = function ( pageTitle ) {
		const slot = 'main';
		const params = {
			action: 'query',
			format: 'json',
			formatversion: 2,
			prop: 'revisions',
			rvslots: slot,
			rvprop: 'content',
			titles: pageTitle,
			rvlimit: 1
		};

		return mw_get_deferred( params,
			function ( data ) {
				return data.query.pages[ 0 ].revisions[ 0 ].slots[ slot ].content;
			},
			emptyArrayFunc
		);
	};

	function unique_pages( a ) {
		var seen = {};
		return a.filter( function ( item ) {
			return seen.hasOwnProperty( item.pageid ) ? false : ( seen[ item.pageid ] = true );
		} );
	}

	/* -------------------------------------------------- */

	/*
	 * A widget which looks up pages from a certain namespace by prefix
	 * If no input is given, it uses the previous edits of a given user
	 */
	var PageLookupTextInputWidget = function PageLookupTextInputWidget( config ) {
		OO.ui.TextInputWidget.call( this, $.extend( { /* validate: 'integer' */ }, config ) );
		OO.ui.mixin.LookupElement.call( this, $.extend( {
			allowSuggestionsWhenEmpty: true
		}, config ) );

		this.namespaces = config.namespaces || [];
		this.user = config.user;
	};
	OO.inheritClass( PageLookupTextInputWidget, OO.ui.TextInputWidget );
	OO.mixinClass( PageLookupTextInputWidget, OO.ui.mixin.LookupElement );

	PageLookupTextInputWidget.prototype.getLookupRequest = function () {

		var value = this.getValue();

		var deferred;

		if ( !value && this.user ) {
			deferred = get_last_edited( this.user, this.namespaces );
		} else {
			deferred = get_page_suggestions( value, this.namespaces );
		}

		return deferred.promise( {
			abort: function () {}
		} );
	};

	PageLookupTextInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
		return response || [];
	};

	PageLookupTextInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {

		var mow_from_page = function ( page ) {
			return new OO.ui.MenuOptionWidget( {
				data: page.title,
				label: page.title
			} );
		};

		data = unique_pages( data );
		return data.map( mow_from_page );
	};

	/* -------------------------------------------------- */

	/*
	 * A widget which looks up contributions by a certain user
	 */
	var ContribLookupTextInputWidget = function ContribLookupTextInputWidget( config ) {
		OO.ui.TextInputWidget.call( this, $.extend( { /* validate: 'integer' */ }, config ) );
		OO.ui.mixin.LookupElement.call( this, $.extend( {
			allowSuggestionsWhenEmpty: true
		}, config ) );

		this.namespace = config.namespace;
		this.user = config.user;
	};
	OO.inheritClass( ContribLookupTextInputWidget, OO.ui.TextInputWidget );
	OO.mixinClass( ContribLookupTextInputWidget, OO.ui.mixin.LookupElement );

	ContribLookupTextInputWidget.prototype.getLookupRequest = function () {
		var ns = this.namespace ? [ this.namespace ] : undefined;

		var deferred = get_last_contribs( this.user, ns );

		return deferred.promise( {
			abort: function () {}
		} );
	};

	ContribLookupTextInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
		return response || [];
	};

	ContribLookupTextInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {

		var mow_from_contrib = function ( edit ) {
			var time = edit.timestamp.replace( 'Z', '' ).replace( 'T', ' ' );
			var size = ( ( edit.size >= 0 ) ? '+' : '' ) + String( edit.size );

			var content = [
				new OO.ui.HtmlSnippet( "<p class='userjs-maintain-extra'>" +
					edit.revid + ' (' + size + ', ' + time + ')</p>' )
			];

			if ( edit.comment ) {
				content.push(
					new OO.ui.HtmlSnippet( "<p class='userjs-maintain-extra'>" + edit.comment + '</p>' )
				);
			}

			return new OO.ui.MenuOptionWidget( {
				data: edit.revid,
				text: edit.title,
				content: content
			} );
		};

		return data.map( mow_from_contrib );
	};

	/* -------------------------------------------------- */

	/*
	 * A widget which looks up wikitext lines from a page, optionally matching filters
	 */
	var WikitextLineLookup = function ( config ) {
		OO.ui.TextInputWidget.call( this, $.extend( { /* validate: 'integer' */ }, config ) );
		OO.ui.mixin.LookupElement.call( this, $.extend( {
			allowSuggestionsWhenEmpty: true
		}, config ) );

		this.page = config.page;

		const filters = config.filters;

		this.wikitextPromise = getPageWikitext( this.page )
			.then( ( wikitext ) => {
				let lines = wikitext.split( '\n' );

				if ( filters ) {
					lines = lines.filter( ( line ) => {
						for ( const filter of filters ) {
							if ( filter.test( line ) ) {
								return true;
							}
						}
						return false;
					} );
				}

				this.wikitextLines = lines;
			} );
	};
	OO.inheritClass( WikitextLineLookup, OO.ui.TextInputWidget );
	OO.mixinClass( WikitextLineLookup, OO.ui.mixin.LookupElement );

	WikitextLineLookup.prototype.getLookupRequest = function () {
		const deferred = $.Deferred();

		const value = this.getValue().toLowerCase();
		const matches = this.wikitextLines.filter( ( l ) => {
			return l.toLowerCase().indexOf( value ) !== -1;
		} );

		// wait for the wikitext to load
		this.wikitextPromise.then( () => {
			deferred.resolve( matches );
		} );

		return deferred.promise( {
			abort: function () {}
		} );
	};

	WikitextLineLookup.prototype.getLookupCacheDataFromResponse = function ( response ) {
		return response || [];
	};

	WikitextLineLookup.prototype.getLookupMenuOptionsFromData = function ( data ) {

		var mowFromLine = function ( line ) {
			return new OO.ui.MenuOptionWidget( {
				data: line,
				text: line
			} );
		};

		return data.map( mowFromLine );
	};

	/* -------------------------------------------------- */

	function PrimaryActionOnEnterMixin( /* config */ ) {
		this.onDialogKeyDownHandler = this.onDialogKeyDown.bind( this );
	}
	OO.initClass( PrimaryActionOnEnterMixin );

	PrimaryActionOnEnterMixin.prototype.onDialogKeyDown = function ( e ) {
		var actions;
		if ( e.which === OO.ui.Keys.ENTER ) {
			actions = this.actions.get( { flags: 'primary', visible: true, disabled: false } );
			if ( actions.length > 0 ) {
				this.executeAction( actions[ 0 ].getAction() );
				e.preventDefault();
				e.stopPropagation();
			}
		} else if ( e.which === OO.ui.Keys.ESCAPE ) {
			actions = this.actions.get( { flags: 'safe', visible: true, disabled: false } );
			this.executeAction( actions[ 0 ].getAction() );
			e.preventDefault();
			e.stopPropagation();
		}
	};

	/* -------------------------------------------------- */

	var dialogs = {};

	dialogs.ActionChooseDialog = function ( config ) {
		dialogs.ActionChooseDialog.super.call( this, config );
		// mixin constructors
		PrimaryActionOnEnterMixin.call( this );
	};
	OO.inheritClass( dialogs.ActionChooseDialog, OO.ui.ProcessDialog );
	OO.mixinClass( dialogs.ActionChooseDialog, PrimaryActionOnEnterMixin );

	// Specify a name for .addWindows()
	dialogs.ActionChooseDialog.static.name = 'ActionChooseDialog';
	// Specify a title statically (or, alternatively, with data passed to the opening() method).
	dialogs.ActionChooseDialog.static.title = 'Choose maintenance action';

	dialogs.ActionChooseDialog.static.actions = [
		{
			action: 'save',
			label: 'Done',
			flags: [ 'primary' ],
			modes: [ 'can_execute' ]
		},
		{
			label: 'Cancel',
			flags: [ 'safe' ],
			modes: [ 'executing', 'can_execute' ]
		}
	];

	dialogs.ActionChooseDialog.prototype.addFieldFromNeed = function ( need ) {
		var input;
		// most widgets can use this
		var valuefunc = function ( i ) {
			return i.getValue();
		};

		var eventName;

		switch ( need.type ) {
			case 'page':
				input = new PageLookupTextInputWidget( {
					namespaces: need.namespaces,
					user: mw.config.get( 'wgUserName' )
					// placeholder: "Index:Filename.djvu"
				} );
				break;
			case 'text':
				input = new OO.ui.TextInputWidget();
				break;
			case 'number':
				input = new OO.ui.NumberInputWidget( {
					label: need.label,
					value: need.value,
					min: need.min,
					max: need.max
				} );
				break;
			case 'bool':
				input = new OO.ui.ToggleSwitchWidget( {
					label: need.label,
					value: need.value
				} );
				// normal valuefunc
				break;
			case 'choice':
				input = new OO.ui.DropdownWidget( {
					label: need.label,
					menu: {
						items: need.options.map( function ( c ) {
							return new OO.ui.MenuOptionWidget( {
								data: c.data,
								label: c.label
							} );
						} )
					}
				} );
				valuefunc = function ( i ) {
					return i.getMenu().findSelectedItem().getData();
				};
				break;
			case 'radio-button':
				var items = need.options.map( function ( c ) {
					return new OO.ui.ButtonOptionWidget( {
						data: c.data,
						label: c.label
					} );
				} );

				input = new OO.ui.ButtonSelectWidget( {
					items: items
				} );

				valuefunc = function ( i ) {
					return i.findSelectedItem().getData();
				};

				eventName = 'choose';

				break;
			case 'contrib':
				input = new ContribLookupTextInputWidget( {
					user: need.user
				} );
				break;
			case 'wikitext-line':
				input = new WikitextLineLookup( {
					filters: need.filters,
					page: need.page || mw.config.get( 'wgPageName' )
				} );
				break;
			default:
				console.error( 'Unknown type ' + need.type );
		}

		if ( input ) {
			this.inputs.push( {
				widget: input,
				valuefunc: valuefunc
			} );
			var fl = new OO.ui.FieldLayout( input, {
				label: need.label,
				help: need.help,
				align: 'top'
			} );

			var dialog = this;

			// if the need is submit=true
			if ( need.submit && eventName ) {
				input.on( eventName, function () {
					dialog.executeAction( 'save' );
				} );
			}

			// disable help tabbing, which gets in the way a LOT
			fl.$element.find( '.oo-ui-fieldLayout-help a' )
				.attr( 'tabindex', '-1' );

			this.param_fieldset.addItems( [ fl ] );
		}
	};

	// Customize the initialize() function: This is where to add content
	// to the dialog body and set up event handlers.
	dialogs.ActionChooseDialog.prototype.initialize = function () {
		// Call the parent method.
		dialogs.ActionChooseDialog.super.prototype.initialize.call( this );
		// Create and append a layout and some content.
		this.fieldset = new OO.ui.FieldsetLayout( {
			label: 'Please choose an action',
			classes: [ 'userjs-maintain_fs' ]
		} );

		this.$body.append( this.fieldset.$element );

		var dialog = this;

		this.buttonGroup = new OO.ui.ButtonSelectWidget( {
			items: []
		} );
		this.fieldset.addItems( [ this.buttonGroup ] );

		this.inputs = [];
		this.param_fieldset = new OO.ui.FieldsetLayout( {
			label: 'Action parameters',
			classes: [ 'userjs-maintain_fs' ]
		} );
		this.$body.append( this.param_fieldset.$element );

		this.param_fieldset.toggle( false );

		this.buttonGroup.on( 'select', function ( e ) {

			for ( var i = 0; i < dialog.inputs.length; ++i ) {
				dialog.param_fieldset.clearItems();
			}
			dialog.inputs = [];

			if ( !e ) {
				return; // unselection
			}

			if ( e.data.needs ) {

				for ( var n = 0; n < e.data.needs.length; ++n ) {
					var need = e.data.needs[ n ];

					dialog.addFieldFromNeed( need );
				}

				dialog.param_fieldset.toggle( dialog.inputs.length > 0 );
			} else {
				// immediate
				dialog.executeAction( 'save' );
			}
		} );
	};

	dialogs.ActionChooseDialog.prototype.getActionProcess = function ( action ) {
		var dialog = this;
		if ( action === 'save' ) {
			return new OO.ui.Process( function () {
				var btn = dialog.buttonGroup.findSelectedItem();

				var params = [];

				for ( var i = 0; i < dialog.inputs.length; ++i ) {
					params.push( dialog.inputs[ i ].valuefunc( dialog.inputs[ i ].widget ) );
				}

				if ( !btn ) {
					OO.ui.alert( 'Please choose an action.' );
				} else {
					dialog.actions.setMode( 'executing' );
					var accepted_promise = dialog.saveCallback( btn.data, params );
					// close the dialog if the user accepted the edit
					// and the edit succeeded
					accepted_promise
						.then( function () {
							console.log( 'Accepted' );
							dialog.close();
						}, function () {
							console.log( 'Not accepted/failed' );
							dialog.buttonGroup.unselectItem();
							dialog.actions.setMode( 'can_execute' );
						} );
				}
			} );
		} else {
			return new OO.ui.Process( function () {
				dialog.cancelCallback();
				dialog.close();
			} );
		}
	};

	// Use getSetupProcess() to set up the window with data passed to it at the time
	// of opening (e.g., url: 'http://www.mediawiki.org', in this example).
	dialogs.ActionChooseDialog.prototype.getSetupProcess = function ( data ) {
		data = data || {};
		return dialogs.ActionChooseDialog.super.prototype.getSetupProcess.call( this, data )
			.next( function () {
				var dialog = this;

				var add_button = function ( opts ) {
					var btn = new OO.ui.ButtonOptionWidget( {
						label: opts.label,
						title: opts.help,
						data: opts
					} );

					dialog.buttonGroup.addItems( [ btn ] );
				};

				// Set up contents based on data

				for ( var i = 0; i < data.tools.length; ++i ) {
					var tool = data.tools[ i ];
					add_button( tool );
				}

				this.saveCallback = data.saveCallback;
				this.cancelCallback = data.cancelCallback;

				this.actions.setMode( 'can_execute' );

			}, this );
	};

	dialogs.ActionChooseDialog.prototype.getBodyHeight = function () {
		// Note that "expanded: false" must be set in the panel's configuration for this to work.
		// When working with a stack layout, you can use:
		//   return this.panels.getCurrentItem().$element.outerHeight( true );
		return 300;
	};

	/* ---------------------------------------------------- */

	function DiffConfirmDialog( config ) {
		DiffConfirmDialog.super.call( this, config );
	}
	OO.inheritClass( DiffConfirmDialog, OO.ui.ProcessDialog );

	// Specify a name for .addWindows()
	DiffConfirmDialog.static.name = 'diffConfirmDialog';
	// Specify a title statically (or, alternatively, with data passed to the opening() method).
	DiffConfirmDialog.static.title = 'Confirm change';

	DiffConfirmDialog.static.actions = [
		{ action: 'save', label: 'Done', flags: 'primary' },
		{ label: 'Cancel', flags: 'safe' }
	];

	// Customize the initialize() function: This is where to add content to
	// the dialog body and set up event handlers.
	DiffConfirmDialog.prototype.initialize = function () {
		// Call the parent method.
		DiffConfirmDialog.super.prototype.initialize.call( this );
		// Create and append a layout and some content.
		this.content = new OO.ui.PanelLayout( {
			padded: true,
			expanded: false
		} );

		this.$pageTitleElem = $( '<span>' );

		$( '<p>' )
			.append( 'Page: ', this.$pageTitleElem )
			.appendTo( this.content.$element );

		$( '<p>' )
			.append( 'Please confirm the changes:' )
			.appendTo( this.content.$element );

		this.$body.append( this.content.$element );

		this.summary = new OO.ui.TextInputWidget( {
			placeholder: 'Summary'
		} );

		this.content.$element.append( this.summary.$element );
	};

	DiffConfirmDialog.prototype.getActionProcess = function ( action ) {
		var dialog = this;
		if ( !this.no_changes && action === 'save' ) {
			return new OO.ui.Process( function () {
				dialog.saveCallback( {
					summary: dialog.summary.getValue()
				} );
				dialog.close();
			} );
		} else {
			return new OO.ui.Process( function () {
				dialog.cancelCallback();
				dialog.close();
			} );
		}
	};

	// Use getSetupProcess() to set up the window with data passed to it at the time
	// of opening (e.g., url: 'http://www.mediawiki.org', in this example).
	DiffConfirmDialog.prototype.getSetupProcess = function ( data ) {
		data = data || {};
		var dialog = this;
		return DiffConfirmDialog.super.prototype.getSetupProcess.call( this, data )
			.next( function () {
				// Set up contents based on data
				dialog.saveCallback = data.saveCallback;
				dialog.cancelCallback = data.cancelCallback;
				dialog.pageTitle = data.pageTitle;

				dialog.$pageTitleElem
					.empty()
					.append( $( '<a>' )
						.attr( 'href', mw.config.get( 'wgArticlePath' ).replace( '$1', dialog.pageTitle ) )
						.append( dialog.pageTitle )
					);

				var shortened = inductiveload.difference.shortenDiffString( data.diff, 100 ).join( '<hr />' );

				if ( shortened && shortened.length ) {
					this.content.$element.append( "<pre class='userjs-maintain-diff'>" + shortened + '</pre>' );
					this.no_changes = false;
				} else {
					this.content.$element.append( '<br>', new OO.ui.MessageWidget( {
						type: 'notice',
						inline: true,
						label: 'No changes made.'
					} ).$element );
					this.no_changes = true;
				}

				dialog.summary.setValue( data.summary );

			}, this );
	};

	/* ---------------------------------------------------- */
	function RegexTextInputWidget( config ) {
		// Configuration initialization
		config = $.extend( {
			validate: this.validate,
			getCaseSensitivity: function () { return true; }
		}, config );
		// Parent constructor
		RegexTextInputWidget.super.call( this, config );
		// Properties
		this.text = null;
		this.getCaseSensitivity = config.getCaseSensitivity;
		this.getUseRegex = config.getUseRegex;
		// Events
		this.connect( this, {
			change: 'onChange'
		} );
		// Initialization
		this.setWorkingText( '' );
	}
	OO.inheritClass( RegexTextInputWidget, OO.ui.ComboBoxInputWidget );

	RegexTextInputWidget.prototype.setWorkingText = function ( text ) {
		this.text = text;
		this.emit( 'change' );
	};

	RegexTextInputWidget.prototype.escapeRegex = function ( string ) {
		return string.replace( /[-/\\^$*+?.()|[\]{}]/g, '\\$&' );
	};

	RegexTextInputWidget.prototype.makeRegexp = function ( value ) {

		if ( !value ) {
			value = this.getValue();
		}

		if ( !this.getUseRegex() ) {
			value = this.escapeRegex( value );
		}

		var flags = 'g';

		if ( !this.getCaseSensitivity() ) {
			flags += 'i';
		}

		return new RegExp( value, flags );
	};

	RegexTextInputWidget.prototype.validate = function ( value ) {

		if ( !this.getUseRegex() ) {
			return true;
		}

		try {
			this.makeRegexp( value );
		} catch ( e ) {
			return false;
		}
		return true;
	};

	RegexTextInputWidget.prototype.onChange = function ( value ) {
		var label;
		if ( !value ) {
			value = this.getValue();
		}
		if ( !this.text ) {
			label = 'loading';
		} else if ( !value ) {
			label = '';
		} else {
			try {
				var regex = this.makeRegexp( value );
				var matches = this.text.match( regex );
				var count = matches ? matches.length : 0;

				var suff = ( count === 1 ) ? 'match' : 'matches';
				label = String( count ) + ' ' + suff;
			} catch ( e ) {
				label = 'bad regex';
			}
		}

		this.setLabel( label );
	};
	/* ---------------------------------------------------- */
	function AutocompleteController( entries ) {
		this.maxEntries = 100;
		this.entries = entries;
	}

	AutocompleteController.prototype.addEntry = function ( content ) {
		// trim list and filter in-place
		this.entries.splice( 0, this.maxEntries - 1, ...this.entries.filter( ( e ) => {
			return e.content !== content;
		} ) );

		this.entries.push( {
			content: content
		} );
	};

	/* ---------------------------------------------------- */

	function ReplaceDialog( config ) {
		ReplaceDialog.super.call( this, config );
		PrimaryActionOnEnterMixin.call( this );

		this.storageId = 'gadget-maintain-replace-config';

		this.config = mw.storage.getObject( this.storageId ) || {
			patterns: [],
			replacements: [],
			isRegex: true,
			caseSensitive: true,
			version: 1
		};

		this.autocompletes = {
			patterns: new AutocompleteController( this.config.patterns ),
			replacements: new AutocompleteController( this.config.replacements )
		};
	}
	OO.inheritClass( ReplaceDialog, OO.ui.ProcessDialog );
	OO.mixinClass( ReplaceDialog, PrimaryActionOnEnterMixin );

	// Specify a name for .addWindows()
	ReplaceDialog.static.name = 'replaceDialog';
	// Specify a title statically (or, alternatively, with data passed to the opening() method).
	ReplaceDialog.static.title = 'Replace in page';

	ReplaceDialog.static.actions = [
		{ action: 'save', label: 'Done', flags: 'primary' },
		{ label: 'Cancel', flags: 'safe' }
	];

	ReplaceDialog.prototype.storeConfig = function () {
		mw.storage.setObject( this.storageId, this.config );
	};

	// Customize the initialize() function: This is where to add content to
	// the dialog body and set up event handlers.
	ReplaceDialog.prototype.initialize = function () {
		// Call the parent method.
		ReplaceDialog.super.prototype.initialize.call( this );
		var dialog = this;

		// Create and append a layout and some content.
		this.fieldset = new OO.ui.FieldsetLayout( {
			label: 'Replacement set-up',
			classes: [ 'userjs-maintain_fs' ]
		} );

		// Add the FieldsetLayout to a FormLayout.
		var form = new OO.ui.FormLayout( {
			items: [ this.fieldset ]
		} );

		this.$body.append( form.$element );

		this.inputs = {};

		this.inputs.case_sensitive = new OO.ui.ToggleSwitchWidget( {
			help: 'Case sensitive search',
			value: true
		} );
		this.inputs.use_regex = new OO.ui.ToggleSwitchWidget( {
			help: 'Regular expression search',
			value: true
		} );
		this.inputs.search = new RegexTextInputWidget( {
			placeholder: 'Search pattern',
			name: 'search-pattern',
			getCaseSensitivity: function () {
				return dialog.inputs.case_sensitive.getValue();
			},
			getUseRegex: function () {
				return dialog.inputs.use_regex.getValue();
			},
			options: this.config.patterns.map( ( r ) => {
				return {
					data: r.content,
					label: r.content
				};
			} ),
			menu: {
				filterFromInput: true,
				filterMode: 'substring'
			}
		} );
		this.inputs.replace = new OO.ui.ComboBoxInputWidget( {
			placeholder: 'Replacement pattern',
			name: 'replace-pattern',
			options: this.config.replacements.map( ( r ) => {
				return {
					data: r.content,
					label: r.content
				};
			} ),
			menu: {
				filterFromInput: true,
				filterMode: 'substring'
			}
		} );

		this.inputs.case_sensitive.on( 'change', function () {
			dialog.inputs.search.emit( 'change' );
		} );
		this.inputs.use_regex.on( 'change', function () {
			dialog.inputs.search.emit( 'change' );
		} );

		this.fieldset.addItems( [
			new OO.ui.FieldLayout( this.inputs.search, {
				label: 'Search for',
				align: 'right',
				help: 'Enter the search pattern as a JS regex (without slashes). E.g. Chapter \\d+ to match chapter headings.'
			} ),
			new OO.ui.FieldLayout( this.inputs.replace, {
				label: 'Replacement',
				align: 'right',
				help: 'Use $1, $2, etc. for captured groups.'
			} ),
			new OO.ui.FieldLayout( this.inputs.case_sensitive, {
				label: 'Case sensitive',
				align: 'right'
			} ),
			new OO.ui.FieldLayout( this.inputs.use_regex, {
				label: 'Regular expression',
				align: 'right',
				help: 'Use a regular expression replacement pattern, rather than plain text.'
			} )
		] );

		// disable help tabbing, which gets in the way a LOT
		this.fieldset.$element.find( '.oo-ui-fieldLayout-help a' )
			.attr( 'tabindex', '-1' );
	};

	ReplaceDialog.prototype.getSetupProcess = function ( data ) {
		data = data || {};
		var dialog = this;
		return ReplaceDialog.super.prototype.getSetupProcess.call( this, data )
			.next( function () {
				// Set up contents based on data
				this.saveCallback = data.saveCallback;
				this.cancelCallback = data.cancelCallback;
				this.pageTitle = data.pageTitle;

				if ( data.selection ) {
					this.inputs.search.setValue( data.selection );
					this.inputs.replace.setValue( data.selection );
				}

				// get and cache the current wikitext
				get_page_wikitext( this.pageTitle )
					.done( function ( wikitext ) {
						dialog.wikitext = wikitext;
						dialog.inputs.search.setWorkingText( wikitext );
					} );
			}, this );
	};

	ReplaceDialog.prototype.getActionProcess = function ( action ) {

		var unescapeBackslashes = function ( s ) {
			return s.replace( /\\n/g, '\n' );
		};

		var dialog = this;
		if ( !this.no_changes && action === 'save' ) {
			const patternString = dialog.inputs.search.getValue();
			const regex = dialog.inputs.search.makeRegexp( null );
			const repl = dialog.inputs.replace.getValue();

			// store autocompletion strings
			this.autocompletes.patterns.addEntry( patternString );
			this.autocompletes.replacements.addEntry( repl );

			this.storeConfig();

			return new OO.ui.Process( function () {
				var summary = 'Replaced: ';
				if ( dialog.inputs.use_regex.getValue() ) {
					summary += regex + ' → ' + repl;
				} else {
					summary += patternString + ' → ' + repl;
				}

				var acceptedPromise = dialog.saveCallback( {
					regex: regex,
					replace: unescapeBackslashes( repl ),
					summary: summary
				} );

				// close the dialog if the user accepted the edit
				// and the edit succeeded
				acceptedPromise
					.then( function () {
						dialog.close();
					} );
			} );
		} else {
			return new OO.ui.Process( function () {
				dialog.close();
			} );
		}
	};

	/* ---------------------------------------------------- */

	var Maintain = {
		windowManager: undefined,
		activated: false,
		reloadOnChange: true,
		defaultTags: [ 'maintain.js' ],
		tools: [],
		/* list of filters that match a tool and mean it doesn't need confirming */
		noconfirm_tools: [],
		signature: 'maintain_replace'
	};

	mw.messages.set( {
		'maintainjs-name': 'maintain.js',
		'maintainjs-docpage': 'User:Inductiveload/maintain'
	} );

	function getSummarySuffix() {
		return '([[' + mw.msg( 'maintainjs-docpage' ) + '|' + mw.msg( 'maintainjs-name' ) + ']])';
	}

	function editPageApi( pageTitle, transformFunction, confirm, minor ) {
		console.log( `Making API edit on ${pageTitle}` );

		var api = new mw.Api();
		var revid = mw.config.get( 'wgRevisionId' );
		var title = pageTitle;

		var promise;
		if ( revid !== 0 ) {

			// wrap the transform function to get the content out of a revision
			const revEditFunction = function ( revision ) {
				return transformFunction( revision.content, confirm )
					.then( function ( transformed ) {
						transformed.summary += ' ' + getSummarySuffix();

						if ( minor !== undefined ) {
							transformed.minor = minor;
						}
						return transformed;
					} );
			};

			promise = api.edit( title, revEditFunction );
		} else {

			// do the transform ourselves on an empty string
			var transform_promise = transformFunction( '', confirm );

			promise = transform_promise
				.then( function ( transformed ) {
					api.create( title,
						{
							summary: transformed.summary + ' ' + getSummarySuffix()
						},
						transformed.text
					);
				} );
		}

		promise
			.then( function () {
				console.log( 'Page updated!' );

				if ( Maintain.reloadOnChange ) {
					location.reload();
				}
			}, function ( e, more ) {

				// console.log("Edit/create rejected");

				if ( e.name === 'EditCancelledException' ) {
					// console.log(e.message);
					return Promise.reject( 'EditCancelledException' );
				} else {
					OO.ui.alert(
						more ? more.error.info : e, {
							title: 'Edit failed'
						} );
				}
			} )
			.catch( function ( e ) {} );

		return promise;
	}

	function editPageTextbox( transform_function, confirm ) {
		console.log( 'Making textbox edit' );

		// eslint-disable-next-line no-jquery/no-global-selector
		var $input = $( '#wpTextbox1' );
		// eslint-disable-next-line no-jquery/no-global-selector
		var $summary = $( '#wpSummary' );

		// do the transform ourselves on an empty string
		var transform_promise = transform_function( $input.val(), confirm );

		var promise = transform_promise
			.then( function ( transformed ) {
				$input.val( transformed.text );

				var new_summary = $summary.val();
				if ( new_summary ) {
					new_summary += '; ';
				}
				new_summary += transformed.summary;
				$summary.val( new_summary + ' ' + getSummarySuffix() );
			},
			function ( e ) {

				// console.log("Edit/create rejected");

				if ( e.name === 'EditCancelledException' ) {
					// console.log(e.message);
					return Promise.reject( 'EditCancelledException' );
				}
			} )
			.catch( function ( e ) {
				console.error( e );
			} );

		return promise;
	}

	/* ---------------------------------------------------- */

	/*
	 * Edit the page with the given promise-returning function.
	 *
	 * Returns a promise that rejects if the promise does (for
	 * example, if the user rejects the change when prompted)
	 */
	function editPage( pageTitle, transformFunction, confirm, minor ) {

		let retProm;
		if ( [ 'edit', 'submit' ].indexOf( mw.config.get( 'wgAction' ) ) !== -1 ) {
			// editing page - text in textbox
			if ( pageTitle !== mw.config.get( 'wgPageName' ) ) {
				alert( `Cannot edit page ${pageTitle} in this mode.` );
			}
			retProm = editPageTextbox( transformFunction, confirm, minor );
		} else {
			retProm = editPageApi( pageTitle, transformFunction, confirm, minor );
		}
		return retProm;
	}

	function confirmChange( title, old_text, new_text, summary ) {
		var diff_html = inductiveload.difference.diffString( old_text, new_text, false );

		// Make the window.
		var dialog = new DiffConfirmDialog( {
			size: 'medium'
		} );

		// eslint-disable-next-line compat/compat
		var confirmPromise = new Promise( function ( resolve, reject ) {

			// Create and append a window manager, which will open and close the window.
			var windowManager = new OO.ui.WindowManager();
			$( document.body ).append( windowManager.$element );
			windowManager.addWindows( [ dialog ] );
			windowManager.openWindow( dialog, {
				diff: diff_html,
				summary: summary,
				pageTitle: title,
				/* resolve the promise if we confirm */
				saveCallback: function ( confirmed ) {
					resolve( {
						// user provided a better summary
						summary: confirmed.summary
					} );
				},
				cancelCallback: function () {
					reject();
				}
			} );
		} );

		return confirmPromise;
	}

	function EditCancelledException() {
		this.message = 'Edit cancelled';
		this.name = 'EditCancelledException';
	}

	/* Make a transform to hand to the edit API
	 *
	 * Returns a functions that transforms text
	 * and returns promise that resolves if the change is accepted
	 * and is rejected on error or if the user rejects it.
	 *
	 * Resolution: {
	 *   text: text,
	 *   summary: summary
	 * }
	 */
	function getTransformAndConfirmFunction( title, selectionInfo, textTransform ) {
		return function ( old_text, confirm ) {
			old_text = old_text || '';

			var transform_result = textTransform( old_text, selectionInfo );

			// tranform failed! abort!
			if ( transform_result === null ) {
				return new Promise( function ( resolve, reject ) {
					reject( 'Transform failed' );
				} );
			}

			var ret = {
				text: transform_result.text,
				summary: transform_result.summary
			};

			// if (Maintain.defaultTags) {
			//   ret['tags'] = Maintain.defaultTags.join("|");
			// }

			// return a promise that resolves directly
			if ( !confirm ) {
				return new Promise( function ( resolve, reject ) {
					resolve( ret );
				} );
			}

			// return a promise that resolves with the transform
			return confirmChange( title, old_text, transform_result.text, transform_result.summary )
				.then( function ( confirmation ) {
					// update in case user changed it
					ret.summary = confirmation.summary;
					return ret;
				}, function () {
					// rejection
					console.log( 'User rejected' );
					return Promise.reject( new EditCancelledException() );
					// return null;
				} );
		};
	}

	function make_template( template, params, newlines ) {
		var add = '{{' + template;
		if ( params && params.length > 0 ) {
			if ( newlines ) {
				add += '\n | ';
			} else {
				add += '|';
			}
			add += params.join( newlines ? '|' : '\n | ' );
			if ( newlines ) {
				add += '\n';
			}
		}
		add += '}}';

		return add;
	}

	/*
	 * Wrap a template name in {{[[Template:%s|%s]]}} so it shows linked in edit
	 * summaries.
	 */
	function linkify_template( s ) {
		s = s.replace( '{' + '{', '' ).replace( '}}', '' );
		return '{' + '{' + '[' + '[Template:' + s + '|' + s + ']]}}';
	}

	/* Append text, skipping certain lines from the end */
	function append_text( text, appended, skip_line_patts, add_line_after, add_line_before ) {
		var lines = text.split( /\n/ );
		var line_i = lines.length - 1;
		var last_was_blank = false;
		while ( line_i > 0 ) {
			var line = lines[ line_i ];
			var matched = false;

			for ( var i = 0; i < skip_line_patts.length; ++i ) {
				if ( skip_line_patts[ i ].test( line ) ) {
					matched = true;
					break;
				}
			}

			if ( matched ) {
				last_was_blank = /^\s*$/.test( line );
				--line_i;
			} else {
				break;
			}
		}

		if ( add_line_before ) {
			lines[ line_i ] += '\n';
		}

		lines[ line_i ] += '\n' + appended;

		if ( !last_was_blank && add_line_after ) {
			lines[ line_i ] += '\n';
		}

		return lines.join( '\n' );
	}

	function prepend_xfrm( prefix, separation, summary ) {
		return function ( old_text ) {
			return {
				text: prefix + ( old_text ? separation : '' ) + old_text,
				summary: summary || ( "Prepended '" + prefix + "'" )
			};
		};
	}

	function append_xfrm( suffix, separation, summary ) {
		return function ( old_text ) {
			return {
				text: old_text + ( old_text ? separation : '' ) + suffix,
				summary: summary || ( 'Appended: ' + suffix )
			};
		};
	}

	function add_template_transform( append, template, params, config ) {
		config = config || {};
		var add = make_template( template, params, config.params_newlines );

		if ( config.sign ) {
			// eslint-disable-next-line no-useless-concat
			add += ' ~' + '~' + '~' + '~'; // don't replace in JS source
		}

		var separation = config.separation || '\n';

		var summary = config.summary ||
			'Add ' + linkify_template( template );

		if ( append ) {
			return append_xfrm( add, separation, summary );
		}

		return prepend_xfrm( add, separation, summary );
	}

	function add_cat_xfrm( cat ) {
		// stop MW categorising the JS page...
		// eslint-disable-next-line no-useless-concat
		var cat_str = '[[' + 'Category:' + cat + ']]';

		return function ( old ) {
			var lines = old.split( /\n/ );

			var line_i = lines.length - 1;
			while ( line_i > 0 && lines[ line_i ].trim().length === 0 ) {
				line_i--;
			}

			var add_str = '\n' + cat_str;
			if ( !lines[ line_i ].trim().startsWith( '[[Category' ) ) {
				add_str = '\n' + add_str;
			}

			return {
				text: old + add_str,
				summary: 'Add category: ' + cat_str
			};
		};
	}

	/*
	 * Takes a list of [regex, repl] pairs and applies
	 * them in order.
	 *
	 * Returns the transform and the summary as an object
	 */
	function regexTransform( res, summary ) {

		return function ( old ) {
			for ( var i = 0; i < res.length; ++i ) {
				old = old.replace( res[ i ][ 0 ], res[ i ][ 1 ] );
			}
			return {
				text: old,
				summary: summary
			};
		};
	}

	/*
	 * Takes a list of [needle, repl] pairs and applies
	 * them in order. The pattern is a plain string and will match exactly.
	 *
	 * Returns the transform and the summary as an object
	 */
	function replaceTransform( res, summary ) {

		const regexExscapePatt = /[-[\]/{}()*+?.\\^$|]/g;
		const escapeRegex = function ( needle ) {
			return needle.replace( regexExscapePatt, '\\$&' );
		};

		const regexps = res.map( ( [ needle, repl ] ) => {
			return [ new RegExp( escapeRegex( needle ) ), repl ];
		} );

		// defer to the generic regexp transform
		return regexTransform( regexps, summary );
	}

	function delete_templates_transform( templates, summary ) {

		return function ( old ) {
			var removed = [];

			for ( var i = 0; i < templates.length; ++i ) {
				// TODO parse properly to matching braces
				var re = new RegExp( '{{\\s*' + templates[ i ] + '(\\s*\\|.*?)?}}', 'i' );
				var new_text = old.replace( re, '' );

				if ( new_text !== old ) {
					removed.push( templates[ i ] );
					old = new_text;
				}
			}

			if ( !summary ) {
				removed = removed.map( linkify_template ).join( ', ' );
				summary = 'Removed templates: ' + removed;
			}

			return {
				text: old,
				summary: summary
			};
		};
	}

	function chain_transform( transforms, summary ) {

		return function ( old ) {
			var summaries = [];
			for ( var i = 0; i < transforms.length; ++i ) {
				var res = transforms[ i ]( old );

				if ( old !== res.text ) {
					if ( !summary ) {
						summaries.push( res.summary );
					}
					old = res.text;
				}
			}
			return {
				text: old,
				summary: summary || summaries.join( '; ' )
			};
		};
	}

	/* export the transforms */
	inductiveload.maintain.transforms = {
		chain: chain_transform,
		regex: regexTransform,
		replace: replaceTransform,
		add_category: add_cat_xfrm,
		add_template: add_template_transform,
		delete_templates: delete_templates_transform,
		append: append_xfrm,
		prepend: prepend_xfrm
	};
	inductiveload.maintain.utils = {
		make_template_str: make_template,
		linkify_template: linkify_template,
		append_text: append_text
	};

	function generic_match( test, candidate ) {
		if ( typeof test === 'string' ) {
			if ( test === candidate ) {
				return true;
			}
		} else if ( test instanceof RegExp ) {
			if ( test.test( candidate ) ) {
				return true;
			}
		} else if ( test instanceof Function ) {
			if ( test( candidate ) ) {
				return true;
			}
		}
		return false;
	}

	function generic_match_any( test_list, candidate ) {
		for ( var i = 0; i < test_list.length; ++i ) {
			if ( generic_match( test_list[ i ], candidate ) ) {
				return true;
			}
		}
		return false;
	}

	function tool_needs_confirm( noconfirm_list, tool_id ) {
		return !generic_match_any( noconfirm_list, tool_id );
	}

	function apply_config( cfg ) {
		// add the config tools
		[].push.apply( Maintain.tools, cfg.tools );

		[].push.apply( Maintain.noconfirm_tools, cfg.noconfirm_tools );
	}

	function init() {
		if ( !Maintain.activated ) {
			Maintain.windowManager = new OO.ui.WindowManager();
			// Create and append a window manager, which will open and close the window.
			$( document.body ).append( Maintain.windowManager.$element );

			var blank_cfg = {
				tools: [],
				noconfirm_tools: []
			};
			// user-provided configs
			mw.hook( Maintain.signature + '.config' )
				.fire( inductiveload.maintain, blank_cfg );

			apply_config( blank_cfg );

			Maintain.activated = true;
		}
	}

	function getSelection() {
		const selection = window.getSelection();
		let pageName;

		if ( !selection.isCollapsed && selection.rangeCount > 0 ) {
			const $anchor = $( selection.anchorNode );
			const $prpPageCont = $anchor.closest( '.prp-pages-output' );

			if ( $prpPageCont.length > 0 ) {
				// the selection is inside a PRP section

				// find the page marker before the selection
				const $pageMarkers = $prpPageCont.find( '.pagenum' );
				const previous = [ ...$pageMarkers ].filter(
					( elem ) => selection.anchorNode.compareDocumentPosition( elem ) ===
							Node.DOCUMENT_POSITION_PRECEDING
				).pop();

				// pull the page name out of the data-page-name attribute
				pageName = previous.getAttribute( 'data-page-name' );
			}
		}

		if ( !pageName ) {
			// use the current page
			pageName = mw.config.get( 'wgPageName' );
		}

		return {
			selection: selection.toString(),
			pageTitle: pageName
		};
	}

	function activate() {

		init();

		// Make the window.
		var dialog = new dialogs.ActionChooseDialog( {
			size: 'medium'
		} );

		Maintain.windowManager.addWindows( [ dialog ] );

		const selectionInfo = getSelection();

		Maintain.windowManager.openWindow( dialog, {
			tools: Maintain.tools,
			selection: selectionInfo.selection,
			pageTitle: selectionInfo.pageTitle,
			/* resolve the promise if we confirm */
			saveCallback: function ( tool, params ) {
				var transform = tool.transform( params );
				if ( transform ) {
					const title = mw.config.get( 'wgPageName' );
					// convert to an API transform and attempt a page edit
					var transformFn = getTransformAndConfirmFunction(
						title, selectionInfo, transform );

					var confirm = tool_needs_confirm( Maintain.noconfirm_tools, tool.id );

					const minor = false;
					var editPromise = editPage( title, transformFn, confirm, minor );

					return editPromise;
				} else {
					return new Promise( function ( resolve, reject ) {
						reject( 'Transform failed.' );
					} );
				}
			},
			cancelCallback: function () {}
		} );
	}

	function activateReplace() {

		init();

		// Make the window.
		var dialog = new ReplaceDialog( {
			size: 'medium'
		} );

		$( document.body ).append( Maintain.windowManager.$element );
		Maintain.windowManager.addWindows( [ dialog ] );

		var selectionInfo = getSelection();

		Maintain.windowManager.openWindow( dialog, {
			selection: selectionInfo.selection,
			pageTitle: selectionInfo.pageTitle,
			saveCallback: function ( repl_data ) {

				const regex = repl_data.regex;
				const replc = repl_data.replace;
				const summary = repl_data.summary;
				const transform = regexTransform( [ [ regex, replc ] ],
					summary );

				const transform_fn = getTransformAndConfirmFunction(
					selectionInfo.pageTitle, selectionInfo, transform );

				const minor = true;
				const edit_prom = editPage( selectionInfo.pageTitle, transform_fn, true, minor );

				// return the edit promise - if this fails, we ask again
				return edit_prom;
			}
		} );
	}

	function installPortlet() {

		var portlet = mw.util.addPortletLink(
			'p-tb',
			'#',
			'Maintenance',
			't-maintenance',
			'Maintenance tools'
		);

		$( portlet ).on( 'click', function ( e ) {
			e.preventDefault();
			activate();
		} );

		portlet = mw.util.addPortletLink(
			'p-tb',
			'#',
			'Replace',
			't-replace',
			'Replace in page'
		);

		$( portlet ).on( 'click', function ( e ) {
			e.preventDefault();
			activateReplace();
		} );

		console.log( 'Portlet installed' );
	}

	function installCss( css ) {
		// eslint-disable-next-line no-jquery/no-global-selector
		$( 'head' ).append( '<style type="text/css">' + css + '</style>' );
	}

	$( function () {
		installPortlet();

		// Install CSS
		installCss( `
.userjs-maintain_fs {
	padding: 0 16px;
	margin-top: 12px !important;
}
.userjs-maintain-extra {
	font-size: 85%;
}
.userjs-maintain-diff ins {
	color: seagreen;
}
.userjs-maintain-diff del {
	color: tomato;
}
` );
	} );

// eslint-disable-next-line no-undef
}( jQuery, mediaWiki, OO ) );