MediaWiki:Gadget-ACDC.js

/*
 * Add to Commons / Descriptive Claims (ACDC)
 *
 * Gadget to add a collection of statements to a set of files.
 *
 * Documentation: [[Help:Gadget-ACDC]]
 * (https://commons.wikimedia.org/wiki/Help:Gadget-ACDC)
 *
 * This gadget is developed on GitHub;
 * please avoid changing it directly on Wikimedia Commons if you can:
 *
 * https://github.com/lucaswerkmeister/ACDC
 */
( async function ( mw, $ ) {
	'use strict';

	const require = await mw.loader.using( [
			'oojs',
			'oojs-ui-core',
			'oojs-ui-widgets',
			'oojs-ui-windows',
			'oojs-ui.styles.icons-interactions',
			'oojs-ui.styles.icons-content',
			'oojs-ui.styles.icons-editing-list',
			'wikibase.mediainfo.statements',
			'wikibase.utilities.ClaimGuidGenerator',
			'wikibase.datamodel',
			'wikibase.serialization',
			'mediawiki.api',
			'mediawiki.util',
			'mediawiki.Title',
			'jquery.i18n',
			'web2017-polyfills',
		] ),
		ClaimGuidGenerator = wikibase.utilities.ClaimGuidGenerator,
		{ Statement, Claim, PropertyNoValueSnak } = require( 'wikibase.datamodel' ),
		{ StatementListDeserializer, StatementSerializer, StatementDeserializer } = require( 'wikibase.serialization' ),
		{ StatementWidget, AddPropertyWidget } = require( 'wikibase.mediainfo.statements' );

	await $.i18n().load( {
		en: {
			'gadget-acdc-load-category': 'Load category',
			'gadget-acdc-load-pagepile': 'Load PagePile',
			'gadget-acdc-load-category-title': 'Category title:',
			'gadget-acdc-load-category-placeholder': 'Category:Example',
			'gadget-acdc-load-pagepile-id': 'PagePile ID:',
			'gadget-acdc-load-pagepile-error-wrong-wiki': 'That PagePile does not belong to this wiki!',
			'gadget-acdc-load-pagepile-warning-large-pagepile':
				'This PagePile contains {{PLURAL:$1|$1 file|$1 files}}, ' +
				'using it will take a while. Are you sure?',
			'gadget-acdc-button-stop-edit': 'Stop',
			'gadget-acdc-field-files': 'Files to edit',
			'gadget-acdc-field-statements-to-add': 'Statements to add',
			'gadget-acdc-field-statements-to-remove': 'Statements to remove',
			'gadget-acdc-file-placeholder': 'File:Example.png',
			'gadget-acdc-files-placeholder': 'File:Example.png | File:Example.jpg',
			'gadget-acdc-error-duplicate-statements-to-add':
				'You specified multiple statements with the same main value, ' +
				'which is not supported. ' +
				'If you need to make multiple changes to one statement, merge them. ' +
				'If you really need to add multiple statements with the same value, ' +
				'you’ll have to find another way (sorry).',
			'gadget-acdc-error-duplicate-statements-to-remove':
				'You specified multiple statements to remove with the same main value, ' +
				'which is not supported.',
			'gadget-acdc-error-statement-with-qualifiers-to-remove':
				'You specified a statement with qualifiers ' +
				'in the “{{int:gadget-acdc-field-statements-to-remove}}” section. ' +
				'The meaning of this is not clear ' +
				'(remove only qualifiers, or remove whole statement only if it has these qualifiers?), ' +
				'so this is currenty not supported.',
			// TODO implement the following error
			'gadget-acdc-error-statement-to-add-and-remove':
				'You specified statements with the same property and value ' +
				'in the “{{int:gadget-acdc-field-statements-to-add}}” and ' +
				'“{{int:gadget-acdc-field-statements-to-remove}}” sections. ' +
				'The meaning of this is not clear, so it is currently not supported.',
		},
	} );
	await $.i18n().load(
		new mw.Title( 'MediaWiki:Gadget-ACDC-i18n.json' ).getUrl() +
			'?action=raw&ctype=application/json',
		// note: we can’t pass the parameters into getUrl() –
		// we need a URL that ends in .json (otherwise $.i18n thinks it’s a directory),
		// so it has to be /wiki/….json?action=…, not /w/index.php?title=….json&action=…
		// (and yes, this means the i18n only works on wikis with nice URLs)
	);
	// implement {{int:}}, see https://github.com/wikimedia/jquery.i18n/issues/211
	Object.assign( $.i18n.parser.emitter, {
		int( nodes ) {
			return $.i18n( ...nodes );
		},
	} );

	const api = new mw.Api( { userAgent: 'AC/DC (https://commons.wikimedia.org/wiki/Help:Gadget-ACDC)' } );

	/**
	 * Maps titles to entity IDs.
	 *
	 * @param {string[]} titles
	 * @return {Promise<Object.<string,string>>} map from title to entity ID
	 */
	async function titlesToEntityIds( titles ) {
		const allTitles = titles.slice(), // copy that we can splice without affecting the original
			entityIds = {};
		let someTitles;
		while ( ( someTitles = allTitles.splice( 0, 50 ) ).length > 0 ) {
			const response = await api.post( { // POST because titles list may be too long for GET URL
				action: 'query',
				titles: someTitles,
				formatversion: 2,
			} );
			for ( const page of response.query.pages ) {
				entityIds[ page.title ] = `M${ page.pageid }`;
			}
		}
		return entityIds;
	}

	/**
	 * Maps entity IDs to entity data.
	 *
	 * @param {string[]} entityIds
	 * @param {string[]} props
	 * @return {Promise<Object.<string,Object>>} map from entity ID to entity data
	 */
	async function entityIdsToData( entityIds, props ) {
		const allEntityIds = entityIds.slice(), // copy that we can splice without affecting the original
			entityData = {};
		let someEntityIds;
		while ( ( someEntityIds = allEntityIds.splice( 0, 50 ) ).length > 0 ) {
			const response = await api.get( { action: 'wbgetentities', ids: someEntityIds, props, formatversion: 2 } );
			Object.assign( entityData, response.entities );
		}
		for ( const data of Object.values( entityData ) ) {
			if ( 'missing' in data ) {
				// treat missing entities (i. e. no structured data yet) as having empty statements
				data.statements = {};
			}
		}
		return entityData;
	}

	// TODO change back to an async generator function once MediaWiki supports for-await-of (T395347)
	async function categoryFiles( categoryTitle, callback ) {
		const originalParams = {
			action: 'query',
			list: 'categorymembers',
			cmtitle: categoryTitle,
			cmprop: [ 'title' ],
			cmtype: [ 'file' ],
			cmlimit: 'max',
			formatversion: 2,
		};
		let response = {};
		do {
			response = await api.get( Object.assign( {}, originalParams, response.continue ) );
			for ( const { title } of response.query.categorymembers ) {
				await callback( title );
			}
		} while ( 'continue' in response );
	}

	/**
	 * Return the datatypes of the properties with the given IDs.
	 *
	 * @param {Array.<string>} propertyIds
	 * @return {Promise<Object.<string,string>>}
	 */
	async function propertyDatatypes( propertyIds ) {
		if ( !propertyIds.length ) {
			return {};
		}
		const response = await api.get( {
			action: 'wbgetentities',
			ids: propertyIds,
			props: [ 'datatype' ],
			formatversion: 2,
		} );
		// TODO change back to Object.fromEntries() once MediaWiki supports ES2019
		const datatypes = {};
		for ( const [ propertyId, { datatype } ] of Object.entries( response.entities ) ) {
			datatypes[ propertyId ] = datatype;
		}
		return datatypes;
	}

	/**
	 * Sleep for a tiny bit, to give the browser time to update the UI.
	 * Usually called in busy loops that would otherwise block for a while, freezing the browser.
	 * Calling this may slow down the process a bit, but is much more responsive.
	 *
	 * @return {Promise}
	 */
	function microsleep() {
		return new Promise( resolve => setTimeout( resolve, 1 ) );
	}

	function failSanityCheck( component ) {
		throw new Error( `${ component } seems to have changed incompatibly, AC/DC must be updated before it can be safely used!` );
	}

	function sanityCheckStatementWidgetPropertyId() {
		const statementWidget = new StatementWidget( {
			entityId: '',
			propertyId: 'P12345',
			isDefaultProperty: false,
			propertyType: 'wikibase-item',
		} );
		if ( !( 'state' in statementWidget &&
				statementWidget.state.propertyId === 'P12345' ) ) {
			// if the property ID is not available, we can’t detect existing statements
			failSanityCheck( 'StatementWidget.state.propertyId' );
		}
	}

	function sanityCheckStatementEquals() {
		const snak = new PropertyNoValueSnak( 'P1' ),
			claim1 = new Claim( snak, null, 'guid 1' ),
			statement1 = new Statement( claim1 ),
			claim2 = new Claim( snak, null, 'guid 2' ),
			statement2 = new Statement( claim2 );
		if ( !statement1.equals( statement2 ) ) {
			// if different GUIDs break Statement.equals, we can’t detect duplicate statements
			failSanityCheck( 'Statement.equals' );
		}
	}

	sanityCheckStatementWidgetPropertyId();
	sanityCheckStatementEquals();

	let installedStyles = false;
	function installStyles() {
		if ( installedStyles ) {
			return;
		}

		const style = document.createElement( 'style' );
		// TODO better way to indicate errors
		style.innerHTML = `
.acdc-statementsDialog__statementWidget--duplicate-statement,
.acdc-statementsDialog__statementWidget--statement-with-qualifiers-to-remove,
.acdc-statementsDialog__statementWidget--statement-to-add-and-remove {
	border-left: 2px solid red;
}

.acdc-statementsDialog .wbmi-statement-footer {
	display: none;
}

body.acdc-active .uls-menu {
	z-index: 451; /* MediaWiki core @z-index-overlay (450) + 1 */
	--source-of-this-z-index: '[[c:Help:Gadget-ACDC|AC/DC gadget]]';
}
`;
		const now = new Date();
		if ( ( now.getMonth() + 1 ) === 9 && now.getDate() === 23 ) {
			style.innerHTML += `
.acdc-statementsDialog .oo-ui-processDialog-actions-primary .oo-ui-widget-enabled .oo-ui-buttonElement-button {
	background: linear-gradient( #D60270 40%, #9B4F96 40%, 60%, #0038A8 60% );
}
`;
		}

		document.head.appendChild( style );
		installedStyles = true;
	}

	/**
	 * Parse a URL on the local wiki into just a title.
	 *
	 * @param {string|URL} url
	 * @return {string|null} The title, if the URL can be parsed,
	 * otherwise null (invalid URL, not on the local wiki, …)
	 */
	function parseLocalTitle( url ) {
		if ( !( url instanceof URL ) ) {
			try {
				url = new URL( url );
			} catch ( e ) {
				return null;
			}
		}

		if ( !(
			`//${ url.host }` === mw.config.get( 'wgServer' ) ||
			`${ url.protocol }//${ url.host }` === mw.config.get( 'wgServer' )
		) ) {
			return null;
		}

		const articlePath = mw.config.get( 'wgArticlePath' ); // like /wiki/$1
		if ( articlePath.endsWith( '$1' ) && !articlePath.includes( '?' ) ) {
			const articlePathPrefix = articlePath.slice( 0, -2 );
			if ( url.pathname.startsWith( articlePathPrefix ) ) {
				return decodeURIComponent(
					url.pathname.slice( articlePathPrefix.length ),
				).replace( /_/g, ' ' );
			}
		}

		const script = mw.config.get( 'wgScript' ); // like /w/index.php
		if ( url.pathname === script ) {
			const params = url.searchParams;
			if ( params.has( 'title' ) ) {
				return decodeURIComponent(
					params.get( 'title' ),
				).replace( /_/g, ' ' );
			}
		}

		return null;
	}

	/**
	 * Ensure that a title begins with the File: namespace.
	 *
	 * @param {string} title
	 * @return {string}
	 */
	function ensureFileNamespace( title ) {
		if ( title.startsWith( 'File:' ) ) {
			return title;
		} else {
			return `File:${ title }`;
		}
	}

	/**
	 * Try to intelligently parse some input for a file.
	 * The input can be a title with or without the File: namespace,
	 * or a URL to the file on the local wiki.
	 *
	 * @param {string} input
	 * @return {string}
	 */
	function parseFileInput( input ) {
		if ( input.includes( '/' ) ) {
			// file names can never contain a slash, so try to parse as URL
			// (subpages in the File: namespace exist but they’re not files)
			const title = parseLocalTitle( input );
			if ( title !== null && title.startsWith( 'File:' ) ) {
				return title;
			}
		}

		return ensureFileNamespace( input );
	}

	/**
	 * FileInputWidget is an input widget for files on the local wiki.
	 * File names are looked up as soon as the user begins typing,
	 * and suggested accordingly.
	 * When text is pasted into the input,
	 * newline and tab characters are replaced with pipe characters,
	 * for integration with FilesWidget.
	 *
	 * @class
	 * @extends OO.ui.TextInputWidget
	 * @mixins OO.ui.mixin.LookupElement
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 * @cfg {string[]} [skippedFiles] Don’t suggest these files in the lookup.
	 */
	function FileInputWidget( config ) {
		FileInputWidget.super.call( this, Object.assign( {
			placeholder: $.i18n( 'gadget-acdc-file-placeholder' ),
		}, config ) );
		OO.ui.mixin.LookupElement.call( this, Object.assign( {
			showPendingRequest: false,
			$container: this.$input, // the default is this.$element, which in a non-'outline' TagMultiselectWidget is never attached to the DOM, so the lookup can’t position itself relative to it
		}, config ) );
		this.skippedFiles = config.skippedFiles || [];
		this.$element.addClass( 'acdc-fileInputWidget' );
		this.$input.addClass( 'acdc-fileInputWidget-input' );
		this.lookupMenu.connect( this, { choose: [ 'emit', 'select' ] } );
		this.$input.on( 'paste', ( { originalEvent: clipboardEvent } ) => {
			const value = clipboardEvent.clipboardData.getData( 'text' )
					.trim()
					.replace( /[\n\t]/g, ' | ' ),
				inputElement = this.$input[ 0 ];

			if ( typeof inputElement.setRangeText === 'function' ) {
				inputElement.setRangeText( value );
				inputElement.selectionStart += value.length;
				inputElement.selectionEnd = inputElement.selectionStart;
			} else {
				// fallback for incompatible browsers
				inputElement.value = value;
			}

			clipboardEvent.preventDefault();
		} );
	}
	OO.inheritClass( FileInputWidget, OO.ui.TextInputWidget );
	OO.mixinClass( FileInputWidget, OO.ui.mixin.LookupElement );
	FileInputWidget.prototype.setSkippedFiles = function ( skippedFiles ) {
		this.skippedFiles = skippedFiles;
	};
	FileInputWidget.prototype.getLookupRequest = function () {
		const prefix = this.getValue();
		if ( !prefix || prefix.includes( '|' ) ) {
			const response = {
				query: {
					search: [],
				},
				notice: 'This is a fake API response generated by FileInputWidget.prototype.getLookupRequest.',
			};
			return $.Deferred().resolve( response ).promise();
		}

		return api.get( {
			action: 'query',
			list: 'search',
			srsearch: `prefix:${ ensureFileNamespace( prefix ) }`,
			srinfo: [ /* no metadata */ ],
			srprop: [ /* no properties (we only need title, which is always returned) */ ],
			formatversion: 2,
		} );
	};
	FileInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
		return response.query.search.map( result => result.title );
	};
	FileInputWidget.prototype.getLookupMenuOptionsFromData = function ( titles ) {
		return titles
			.filter( title => !this.skippedFiles.includes( title ) )
			.map( title => new OO.ui.MenuOptionWidget( {
				data: title,
				label: title,
			} ) );
	};

	/**
	 * FilesWidget is an input widget for a collection of files on the local wiki.
	 * A FileInputWidget is used for the input,
	 * and the input is split on pipe characters
	 * to allow adding multiple files at once.
	 * The File: namespace is automatically added where missing.
	 * The TagMultiselectWidget’s icon is turned into an icon,
	 * which opens a menu with a button to load files from a PagePile.
	 * Inputs may also be file or PagePile URLs.
	 *
	 * @class
	 * @extends OO.ui.TagMultiselectWidget
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 */
	function FilesWidget( config ) {
		FilesWidget.super.call( this, Object.assign( {
			allowArbitrary: true,
			inputWidget: new FileInputWidget( Object.assign( {
				placeholder: $.i18n( 'gadget-acdc-files-placeholder' ),
			}, config ) ),
			icon: 'ellipsis',
		}, config ) );
		this.input.connect( this, { select: 'addTagFromInput' } );
		this.on( 'change', () => {
			this.input.setSkippedFiles( this.getTitles() );
		} );

		this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
		this.$element.addClass( 'acdc-filesWidget' );

		// we turn the ellipsis icon into a “button” opening a popup menu with currently one button
		this.categoryButton = new OO.ui.ButtonWidget( {
			icon: 'tag',
			label: $.i18n( 'gadget-acdc-load-category' ),
		} );
		this.pagePileButton = new OO.ui.ButtonWidget( {
			icon: 'listBullet',
			label: $.i18n( 'gadget-acdc-load-pagepile' ),
		} );
		this.menuPopup = new OO.ui.PopupWidget( {
			$content: new OO.ui.StackLayout( {
				items: [
					new OO.ui.PanelLayout( {
						$content: this.categoryButton.$element,
						expanded: false,
					} ),
					new OO.ui.PanelLayout( {
						$content: this.pagePileButton.$element,
						expanded: false,
					} ),
				],
				continuous: true,
				expanded: false,
			} ).$element,
			$floatableContainer: this.$icon,
			align: 'forwards',
			autoClose: true,
			$autoCloseIgnore: this.$icon, // click on $icon closes via toggle() below instead
			$overlay: this.$overlay,
			width: null, // use automatic width
			padded: true,
		} );
		this.$overlay.append( this.menuPopup.$element );
		this.$icon.css( { cursor: 'pointer' } );
		this.$icon.on( 'click', () => this.menuPopup.toggle() );
		// TODO this is not very accessible :/
		// but we don’t have many options – we can’t add other elements around the $icon,
		// or the TagMultiselectWidget’s layout breaks

		this.suggestedCurrentCategory = false;
		this.categoryButton.on( 'click', async () => {
			this.menuPopup.toggle( false );

			let defaultCategory = mw.config.get( 'wgPageName' )
				.replace( /_/g, ' ' );
			if ( defaultCategory.startsWith( 'Category:' ) && !this.suggestedCurrentCategory ) {
				this.suggestedCurrentCategory = true;
			} else {
				defaultCategory = null;
			}

			let categoryTitle = await OO.ui.prompt( $.i18n( 'gadget-acdc-load-category-title' ), {
				size: 'medium',
				textInput: {
					placeholder: $.i18n( 'gadget-acdc-load-category-placeholder' ),
					value: defaultCategory,
				},
			} );
			if ( !categoryTitle ) {
				// user clicked “cancel”, nothing to do
				return;
			}

			if ( !categoryTitle.startsWith( 'Category:' ) ) {
				categoryTitle = `Category:${ categoryTitle }`;
			}

			try {
				await this.loadCategory( categoryTitle );
			} catch ( e ) {
				await OO.ui.alert( `Error: ${ e }` );
			}
		} );

		this.pagePileButton.on( 'click', async () => {
			this.menuPopup.toggle( false );
			const pagePileId = await OO.ui.prompt( $.i18n( 'gadget-acdc-load-pagepile-id' ), {
				textInput: {
					placeholder: '12345',
					type: 'number',
				},
			} );
			if ( !pagePileId ) {
				// user clicked “cancel”, nothing to do
				return;
			}

			await this.loadPagePile( pagePileId );
		} );
	}
	OO.inheritClass( FilesWidget, OO.ui.TagMultiselectWidget );
	FilesWidget.prototype.addTagFromInput = function () {
		const titles = this.input.getValue().split( '|' )
			.map( s => s.trim() )
			.filter( s => s )
			.filter( input => {
				// try parsing as well-known URL, skip in that case
				let url;
				try {
					url = new URL( input );
				} catch ( e ) {
					// not a URL
					return true;
				}

				if (
					url.host === 'pagepile.toolforge.org' && url.pathname === '/api.php' ||
					url.host === 'tools.wmflabs.org' && url.pathname === '/pagepile/api.php'
				) {
					// PagePile URL
					const pagePileId = url.searchParams.get( 'id' );
					if ( pagePileId === null || !/^[1-9][0-9]*$/.test( pagePileId ) ) {
						// no valid ID
						return true;
					}
					// load it (asynchronously) and skip input as a file in the meantime
					this.loadPagePile( pagePileId );
					return false;
				}

				const title = parseLocalTitle( url );
				if ( title !== null && title.startsWith( 'Category:' ) ) {
					// Category URL – load (async) and skip input in the meantime
					this.loadCategory( title );
					return false;
				}

				return true;
			} )
			.map( input => parseFileInput( input ) );
		this.clearInput();

		for ( const title of titles ) {
			if ( this.isAllowedData( title ) || this.allowDisplayInvalidTags ) {
				this.addTag( title );
			} else {
				let inputValue = this.input.getValue();
				if ( inputValue ) {
					inputValue += ' | ';
				}
				inputValue += title;
				this.input.setValue( inputValue );
			}
		}
	};
	FilesWidget.prototype.loadCategory = async function ( categoryTitle ) {
		await categoryFiles( categoryTitle, async file => {
			this.addTag( file );
			await microsleep();
		} );
	};
	FilesWidget.prototype.loadPagePile = async function ( pagePileId ) {
		const pileJson = await fetch(
			`https://pagepile.toolforge.org/api.php?action=get_data&id=${ pagePileId }&format=json`,
		).then( r => r.json() );
		if ( pileJson.wiki !== mw.config.get( 'wgDBname' ) ) {
			await OO.ui.alert( $.i18n( 'gadget-acdc-load-pagepile-error-wrong-wiki' ) );
			return false;
		}

		const files = pileJson.pages
			.filter( page => page.startsWith( 'File:' ) );
		if ( files.length >= 100 ) {
			const confirmation = await OO.ui.confirm(
				$.i18n( 'gadget-acdc-load-pagepile-warning-large-pagepile', files.length ),
			);
			if ( !confirmation ) {
				return false;
			}
		}

		for ( const file of files ) {
			this.addTag( file );
			await microsleep();
		}

		return true;
	};
	FilesWidget.prototype.loadCatALot = async function () {
		for ( const [ file ] of mw.libs.catALot.getMarkedLabels() ) {
			if ( file.startsWith( 'File:' ) ) {
				this.addTag( file );
				await microsleep();
			}
		}
	};
	FilesWidget.prototype.getTitles = function () {
		return this.getItems().map( item => item.getData() );
	};

	/**
	 * StatementsProgressBarWidget is a progress bar widget for AC/DC.
	 * It is initialized with the number of files
	 * and number of statements to edit on each file,
	 * and updated whenever progress has been made,
	 * and then calculates the progress itself each time.
	 *
	 * @class
	 * @extends OO.ui.ProgressBarWidget
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 */
	function StatementsProgressBarWidget( config ) {
		StatementsProgressBarWidget.super.call( this, Object.assign( {
			progress: 0,
		}, config ) );
		this.toggle( false );
		this.$element.addClass( 'acdc-statementsProgressBarWidget' );
	}
	OO.inheritClass( StatementsProgressBarWidget, OO.ui.ProgressBarWidget );
	StatementsProgressBarWidget.prototype.enable = function ( numberEntities, numberStatementsPerEntity ) {
		this.numberEntities = numberEntities;
		this.numberStatementsPerEntity = numberStatementsPerEntity;
		this.totalQueryCalls = Math.ceil( numberEntities / 50 );
		this.totalGetEntitiesCalls = Math.ceil( numberEntities / 50 );
		this.totalApiCalls = this.totalQueryCalls + this.totalGetEntitiesCalls + this.numberEntities * this.numberStatementsPerEntity;
		this.loadedEntityIds = false;
		this.loadedEntityData = false;
		this.indexEntity = 0;
		this.indexStatement = 0;
		this.toggle( true );
	};
	StatementsProgressBarWidget.prototype.disable = function () {
		this.toggle( false );
		this.setProgress( 0 );
	};
	StatementsProgressBarWidget.prototype.updateProgress = function () {
		this.setProgress(
			100 * ( ( this.loadedEntityIds ? this.totalQueryCalls : 0 ) +
					( this.loadedEntityData ? this.totalGetEntitiesCalls : 0 ) +
					this.indexEntity * this.numberStatementsPerEntity +
					this.indexStatement ) /
				this.totalApiCalls,
		);
	};
	StatementsProgressBarWidget.prototype.finishedLoadingEntityIds = function () {
		this.loadedEntityIds = true;
		this.updateProgress();
	};
	StatementsProgressBarWidget.prototype.finishedLoadingEntityData = function () {
		this.loadedEntityData = true;
		this.updateProgress();
	};
	StatementsProgressBarWidget.prototype.finishedStatements = function ( numberStatements ) {
		this.indexStatement += numberStatements;
		this.updateProgress();
	};
	StatementsProgressBarWidget.prototype.finishedEntity = function () {
		this.indexEntity++;
		this.indexStatement = 0;
		this.updateProgress();
	};
	StatementsProgressBarWidget.prototype.finished = function () {
		this.setProgress( 100 );
	};

	/**
	 * StatementsDialog is the main dialog of AC/DC.
	 * It initializes and arranges the other UI elements,
	 * and performs the edits on publish.
	 *
	 * Adding multiple statements for the same property with the same value is disallowed –
	 * attempting to do so marks the erroneous statements and disables the publish button.
	 * This is because otherwise, AC/DC updates existing statements with the same value
	 * (i. e. to add qualifiers),
	 * so trying to add more than one such statement does not make sense.
	 *
	 * Likewise, an error is reported if two statements in the “remove” section have the same value,
	 * or if any of them have qualifiers, because the meaning of either would be unclear.
	 *
	 * @class
	 * @extends OO.ui.ProcessDialog
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 * @cfg {string[]} [tags] Change tags to apply to edits.
	 */
	function StatementsDialog( config ) {
		StatementsDialog.super.call( this, Object.assign( {
			size: 'large',
		}, config ) );
		this.tags = ( config || {} ).tags || [];
		this.$element.addClass( 'acdc-statementsDialog' );
	}
	OO.inheritClass( StatementsDialog, OO.ui.ProcessDialog );
	StatementsDialog.static.name = 'statements';
	StatementsDialog.static.title = 'Add to Commons / Descriptive Claims';
	StatementsDialog.static.actions = [
		{
			action: 'save',
			label: mw.message( 'wikibasemediainfo-filepage-publish' ).text(),
			flags: [ 'primary', 'progressive' ],
			modes: [ 'edit' ],
			disabled: true, // see updateCanSave
		},
		{
			label: mw.message( 'wikibasemediainfo-filepage-cancel' ).text(),
			flags: [ 'safe', 'close' ],
			modes: [ 'edit', 'save' ],
		},
		{
			action: 'stop',
			label: $.i18n( 'gadget-acdc-button-stop-edit' ),
			flags: [ 'primary', 'destructive' ],
			modes: [ 'save' ],
		},
	];
	StatementsDialog.prototype.initialize = function () {
		StatementsDialog.super.prototype.initialize.call( this );
		installStyles();

		this.stopped = false;

		this.filesWidget = new FilesWidget( {
			indicator: 'required',
			$overlay: this.$overlay,
		} );
		this.filesWidget.connect( this, { change: 'updateCanSave' } );
		this.filesWidget.connect( this, { change: 'updateSize' } );

		this.hasDuplicateStatementsToAddPerProperty = {};
		this.statementToAddWidgets = [];
		this.addPropertyToAddWidget = new AddPropertyWidget( {
			$overlay: this.$overlay,
		} );
		this.addPropertyToAddWidget.on( 'choose', ( _widget, { id, datatype } ) => this.addStatementToAddWidget( id, datatype ) );
		this.addPropertyToAddWidget.connect( this, { choose: 'updateSize' } );
		// TODO we should also updateSize when the AddPropertyWidget enters/leaves editing mode, but it doesn’t emit an event for that yet

		this.hasDuplicateStatementsToRemovePerProperty = {};
		this.hasStatementWithQualifiersToRemovePerProperty = {};
		this.statementToRemoveWidgets = [];
		this.addPropertyToRemoveWidget = new AddPropertyWidget( {
			$overlay: this.$overlay,
		} );
		this.addPropertyToRemoveWidget.on( 'choose', ( _widget, { id, datatype } ) => this.addStatementToRemoveWidget( id, datatype ) );
		this.addPropertyToRemoveWidget.connect( this, { choose: 'updateSize' } );
		// TODO we should also updateSize when the AddPropertyWidget enters/leaves editing mode, but it doesn’t emit an event for that yet

		const filesField = new OO.ui.FieldLayout( this.filesWidget, {
			label: $.i18n( 'gadget-acdc-field-files' ),
			align: 'top',
			classes: [ 'acdc-statementsDialog-filesField' ],
		} );
		filesField.$header.wrap( '<h3>' );
		const statementsToAddField = new OO.ui.FieldLayout( this.addPropertyToAddWidget, {
			label: $.i18n( 'gadget-acdc-field-statements-to-add' ),
			align: 'top',
			classes: [ 'acdc-statementsDialog-statementsToAddField' ],
		} );
		statementsToAddField.$header.wrap( '<h3>' );
		const statementsToRemoveField = new OO.ui.FieldLayout( this.addPropertyToRemoveWidget, {
			label: $.i18n( 'gadget-acdc-field-statements-to-remove' ),
			align: 'top',
			classes: [ 'acdc-statementsDialog-statementsToRemoveField' ],
		} );
		statementsToRemoveField.$header.wrap( '<h3>' );

		this.content = new OO.ui.PanelLayout( {
			content: [ new OO.ui.FieldsetLayout( {
				items: window.acdcEnableRemoveFeature ? [ // TODO remove this magic global
					filesField,
					statementsToAddField,
					statementsToRemoveField,
				] : [
					filesField,
					statementsToAddField,
				],
			} ) ],
			padded: true,
			expanded: false,
		} );
		this.$body.append( this.content.$element );

		this.statementsProgressBarWidget = new StatementsProgressBarWidget( {} );
		this.$head.append( this.statementsProgressBarWidget.$element );

		this.duplicateStatementsToAddError = new OO.ui.MessageWidget( {
			type: 'error',
			label: $.i18n( 'gadget-acdc-error-duplicate-statements-to-add' ),
		} );
		this.duplicateStatementsToAddError.toggle( false ); // see updateShowDuplicateStatementsToAddError
		this.$foot.append( this.duplicateStatementsToAddError.$element );

		this.duplicateStatementsToRemoveError = new OO.ui.MessageWidget( {
			type: 'error',
			label: $.i18n( 'gadget-acdc-error-duplicate-statements-to-remove' ),
		} );
		this.duplicateStatementsToRemoveError.toggle( false ); // see updateShowDuplicateStatementsToRemoveError
		this.$foot.append( this.duplicateStatementsToRemoveError.$element );

		this.statementWithQualifiersToRemoveError = new OO.ui.MessageWidget( {
			type: 'error',
			label: $.i18n( 'gadget-acdc-error-statement-with-qualifiers-to-remove' ),
		} );
		this.statementWithQualifiersToRemoveError.toggle( false ); // see updateShowStatementWithQualifiersToRemoveError
		this.$foot.append( this.statementWithQualifiersToRemoveError.$element );

		const favoritePropertiesToAdd = window.acdcFavoritePropertiesToAdd ||
			window.acdcFavoriteProperties ||
			mw.config.get( 'wbmiDefaultProperties', [] );
		const favoritePropertiesToRemove = window.acdcFavoritePropertiesToRemove ||
			window.acdcFavoriteProperties ||
			[];
		propertyDatatypes( Array.from( new Set( [ ...favoritePropertiesToAdd, ...favoritePropertiesToRemove ] ) ) ).then( datatypes => {
			for ( const favoritePropertyToAdd of favoritePropertiesToAdd ) {
				this.addStatementToAddWidget( favoritePropertyToAdd, datatypes[ favoritePropertyToAdd ] );
			}
			for ( const favoritePropertyToRemove of favoritePropertiesToRemove ) {
				this.addStatementToRemoveWidget( favoritePropertyToRemove, datatypes[ favoritePropertyToRemove ] );
			}
		} );

		this.filesWidget.loadCatALot().catch( () => {
			// ignore any error (Cat-a-lot not loaded, not active, changed its API…)
		} );
	};
	StatementsDialog.prototype.getSetupProcess = function ( data ) {
		$( document.body ).toggleClass( 'acdc-active', true );
		return StatementsDialog.super.prototype.getSetupProcess.call( this, data ).next( async () => {
			this.title.setLabel( 'AC/DC' );
			this.actions.setMode( 'edit' );
			if ( 'tags' in data ) {
				this.tags = data.tags;
			}
		} );
	};
	StatementsDialog.prototype.getTeardownProcess = function ( data ) {
		$( document.body ).toggleClass( 'acdc-active', false );
		return StatementsDialog.super.prototype.getTeardownProcess.call( this, data );
	};
	StatementsDialog.prototype.getReadyProcess = function ( data ) {
		return StatementsDialog.super.prototype.getReadyProcess.call( this, data ).next( async () => {
			this.filesWidget.updateInputSize();
			this.filesWidget.focus();
		} );
	};
	StatementsDialog.prototype.getActionProcess = function ( action ) {
		switch ( action ) {
			case 'save':
				return new OO.ui.Process( async () => {
					this.actions.setMode( 'save' );

					const finished = await this.save().catch( error => {
						console.error( 'AC/DC: error while saving', error );
						throw new OO.ui.Error( error, { recoverable: false } );
					} );

					if ( finished ) {
						this.actions.setMode( 'edit' );
						this.close();
					}
				} ).next( async () => {
					// regardless whether we finished or stopped, remove the progress bar again
					this.statementsProgressBarWidget.disable();
				} );
			case 'stop':
				return new OO.ui.Process( async () => {
					this.stopped = true;
					this.actions.setMode( 'edit' );
				} );
			default:
				return StatementsDialog.super.prototype.getActionProcess.call( this, action );
		}
	};
	StatementsDialog.prototype.addStatementToAddWidget = function ( id, datatype ) {
		const statementToAddWidget = new StatementWidget( {
			entityId: '', // this widget is reused for multiple entities, we inject the entity IDs on publish
			propertyId: id,
			isDefaultProperty: false,
			propertyType: datatype,
			$overlay: this.$overlay,
			tags: this.tags,
			showControls: true,
		} );
		statementToAddWidget.connect( this, { change: 'updateCanSave' } );
		statementToAddWidget.connect( this, { change: 'updateSize' } );
		statementToAddWidget.on( 'change', () => {
			// check if there are any duplicate statements for this property
			this.hasDuplicateStatementsToAddPerProperty[ id ] = false;
			const itemWidgets = statementToAddWidget.getItems();

			for ( const itemWidget of itemWidgets ) {
				itemWidget.$element.removeClass( 'acdc-statementsDialog__statementWidget--duplicate-statement' );
			}

			// this is O(n²) but for small n
			for ( let i = 0; i < itemWidgets.length; i++ ) {
				const itemWidget1 = itemWidgets[ i ];
				for ( let j = i + 1; j < itemWidgets.length; j++ ) {
					const itemWidget2 = itemWidgets[ j ];
					if ( itemWidget1.getData().getClaim().getMainSnak().equals( itemWidget2.getData().getClaim().getMainSnak() ) ) {
						this.hasDuplicateStatementsToAddPerProperty[ id ] = true;
						itemWidget1.$element.addClass( 'acdc-statementsDialog__statementWidget--duplicate-statement' );
						itemWidget2.$element.addClass( 'acdc-statementsDialog__statementWidget--duplicate-statement' );
					}
				}
			}

			this.updateShowDuplicateStatementsToAddError();
			this.updateCanSave();
		} );
		this.statementToAddWidgets.push( statementToAddWidget );

		statementToAddWidget.$element.insertBefore( this.addPropertyToAddWidget.$element );
	};
	StatementsDialog.prototype.addStatementToRemoveWidget = function ( id, datatype ) {
		const statementToRemoveWidget = new StatementWidget( {
			entityId: '', // this widget is reused for multiple entities, we inject the entity IDs on publish
			propertyId: id,
			isDefaultProperty: false,
			propertyType: datatype,
			$overlay: this.$overlay,
			tags: this.tags,
			showControls: true,
		} );
		statementToRemoveWidget.connect( this, { change: 'updateCanSave' } );
		statementToRemoveWidget.connect( this, { change: 'updateSize' } );
		statementToRemoveWidget.on( 'change', () => {
			// check if there are any duplicate statements or statements with qualifiers for this property
			this.hasDuplicateStatementsToRemovePerProperty[ id ] = false;
			this.hasStatementWithQualifiersToRemovePerProperty[ id ] = false;
			const itemWidgets = statementToRemoveWidget.getItems();

			for ( const itemWidget of itemWidgets ) {
				itemWidget.$element.removeClass( 'acdc-statementsDialog__statementWidget--duplicate-statement' );
				itemWidget.$element.removeClass( 'acdc-statementsDialog__statementWidget--statement-with-qualifiers-to-remove' );
			}

			// this is O(n²) but for small n
			for ( let i = 0; i < itemWidgets.length; i++ ) {
				const itemWidget1 = itemWidgets[ i ];
				for ( let j = i + 1; j < itemWidgets.length; j++ ) {
					const itemWidget2 = itemWidgets[ j ];
					if ( itemWidget1.getData().getClaim().getMainSnak().equals( itemWidget2.getData().getClaim().getMainSnak() ) ) {
						this.hasDuplicateStatementsToRemovePerProperty[ id ] = true;
						itemWidget1.$element.addClass( 'acdc-statementsDialog__statementWidget--duplicate-statement' );
						itemWidget2.$element.addClass( 'acdc-statementsDialog__statementWidget--duplicate-statement' );
					}
				}
			}

			for ( const itemWidget of itemWidgets ) {
				// TODO we don’t check for references here (but WikibaseMediaInfo doesn’t support them yet as of writing this)
				if ( !itemWidget.getData().getClaim().getQualifiers().isEmpty() ) {
					this.hasStatementWithQualifiersToRemovePerProperty[ id ] = true;
					itemWidget.$element.addClass( 'acdc-statementsDialog__statementWidget--statement-with-qualifiers-to-remove' );
				}
			}

			this.updateShowDuplicateStatementsToRemoveError();
			this.updateShowStatementWithQualifiersToRemoveError();
			this.updateCanSave();
		} );
		this.statementToRemoveWidgets.push( statementToRemoveWidget );

		statementToRemoveWidget.$element.insertBefore( this.addPropertyToRemoveWidget.$element );
	};
	StatementsDialog.prototype.onActionClick = function ( action ) {
		if ( !this.isPending() || action.getAction() === 'stop' ) {
			// usually, actions are not executed while pending;
			// however, we want the 'stop' action to go through during save –
			// it would be nice if there was a better way to do this :/
			this.executeAction( action.getAction() );
		}
	};
	/**
	 * Saves changes to the statements.
	 *
	 * @return {Promise<boolean>} Whether the save finished completely (true) or was stopped prematurely (false).
	 */
	StatementsDialog.prototype.save = async function () {
		const titles = this.filesWidget.getTitles();
		this.statementsProgressBarWidget.enable(
			titles.length,
			this.statementToAddWidgets.reduce( ( acc, statementToAddWidget ) => acc + statementToAddWidget.getData().length, 0 ) +
				this.statementToRemoveWidgets.reduce( ( acc, statementToRemoveWidget ) => acc + statementToRemoveWidget.getData().length, 0 ),
		);

		let summary;
		if ( titles.length >= 10 && mw.config.get( 'wgServer' ) === '//commons.wikimedia.org' ) {
			const hash = Math.floor( Math.random() * Math.pow( 2, 48 ) ).toString( 16 );
			summary = `([[:toolforge:editgroups-commons/b/CB/${ hash }|details]])`;
		}

		await Promise.all( this.statementToAddWidgets.map(
			statementToAddWidget => statementToAddWidget.setDisabled( true ).setEditing( false ) ) );
		await Promise.all( this.statementToRemoveWidgets.map(
			statementToRemoveWidget => statementToRemoveWidget.setDisabled( true ).setEditing( false ) ) );

		const entityIds = await titlesToEntityIds( titles );
		this.statementsProgressBarWidget.finishedLoadingEntityIds();

		const entityData = await entityIdsToData( Object.values( entityIds ), [ 'info', 'claims' ] );
		this.statementsProgressBarWidget.finishedLoadingEntityData();

		const statementListDeserializer = new StatementListDeserializer(),
			statementSerializer = new StatementSerializer(),
			statementDeserializer = new StatementDeserializer();
		for ( const [ title, entityId ] of Object.entries( entityIds ) ) {
			const guidGenerator = new ClaimGuidGenerator( entityId );

			for ( const statementToAddWidget of this.statementToAddWidgets ) {
				const previousStatements = statementListDeserializer.deserialize(
					entityData[ entityId ].statements[ statementToAddWidget.state.propertyId ] || [] );
				// TODO change [].concat(...X.map()) back to X.flatMap() once MediaWiki supports ES2019
				const changedStatements = [].concat( ...statementToAddWidget.getData().toArray()
					.map( newStatement => {
						for ( const previousStatement of previousStatements.toArray() ) {
							if ( newStatement.getClaim().getMainSnak().equals( previousStatement.getClaim().getMainSnak() ) ) {
								// main value matches
								if ( newStatement.equals( previousStatement ) ) {
									// full match, do nothing
									return [];
								} else {
									// potentially add qualifiers and bump rank (on a copy of the existing statement)
									// TODO we don’t support references here yet (but neither does WikibaseMediaInfo as of writing this)
									const updatedStatement = statementDeserializer.deserialize(
										statementSerializer.serialize( previousStatement ) );

									updatedStatement.getClaim().getQualifiers().merge( newStatement.getClaim().getQualifiers() );

									if ( newStatement.getRank() !== Statement.RANK.NORMAL &&
										updatedStatement.getRank() === Statement.RANK.NORMAL ) {
										updatedStatement.setRank( newStatement.getRank() );
									}

									if ( updatedStatement.equals( previousStatement ) ) {
										// not equal but no change from our side, do nothing
										return [];
									} else {
										// adding some qualifiers or bumping rank
										return [ updatedStatement ];
									}
								}
							}
						}
						// no existing statement matched, add new
						return [ new Statement(
							new Claim( newStatement.getClaim().getMainSnak(), newStatement.getClaim().getQualifiers(), guidGenerator.newGuid() ),
							newStatement.getReferences(),
							newStatement.getRank(),
						) ];
					} ) );

				for ( const changedStatement of changedStatements ) {
					if ( this.stopped ) {
						this.stopped = false;
						return false;
					}

					await api.postWithEditToken( api.assertCurrentUser( {
						action: 'wbsetclaim',
						claim: JSON.stringify( statementSerializer.serialize( changedStatement ) ),
						baserevid: entityData[ entityId ].lastrevid,
						bot: 1,
						summary,
						tags: this.tags,
						format: 'json',
						formatversion: '2',
						errorformat: 'plaintext',
					} ) ).catch( ( ...args ) => {
						throw args; // jQuery can reject with multiple errors, native promises can’t
					} );
					// TODO handle API errors better
				}

				this.statementsProgressBarWidget.finishedStatements(
					statementToAddWidget.getData().length, // for the progress, we also count statements that didn’t change
				);
			}

			for ( const statementToRemoveWidget of this.statementToRemoveWidgets ) {
				const previousStatements = statementListDeserializer.deserialize(
					entityData[ entityId ].statements[ statementToRemoveWidget.state.propertyId ] || [] );
				// TODO change [].concat(...X.map()) back to X.flatMap() once MediaWiki supports ES2019
				const statementIdsToRemove = [].concat( ...statementToRemoveWidget.getData().toArray()
					.map( statementToRemove => {
						// TODO change [].concat(...X.map()) back to X.flatMap() once MediaWiki supports ES2019
						const matchingStatementIds = [].concat( ...previousStatements.toArray().map( statement => {
							if ( statement.getClaim().getMainSnak().equals( statementToRemove.getClaim().getMainSnak() ) ) {
								return [ statement.getClaim().getGuid() ];
							} else {
								return [];
							}
						} ) );
						if ( matchingStatementIds.length > 1 ) {
							console.warn( `Deleting more than one matching statement on ${ entityId }`, matchingStatementIds );
						}
						return matchingStatementIds;
					} ) );

				for ( const statementIdToRemove of statementIdsToRemove ) {
					if ( this.stopped ) {
						this.stopped = false;
						return false;
					}

					// wbremoveclaims supports removing multiple statements at once, but we edit one at a time to get better edit summaries
					await api.postWithEditToken( api.assertCurrentUser( {
						action: 'wbremoveclaims',
						claim: [ statementIdToRemove ],
						baserevid: entityData[ entityId ].lastrevid,
						bot: 1,
						summary,
						tags: this.tags,
						format: 'json',
						formatversion: '2',
						errorformat: 'plaintext',
					} ) ).catch( ( ...args ) => {
						throw args; // jQuery can reject with multiple errors, native promises can’t
					} );
					// TODO handle API errors better
				}

				this.statementsProgressBarWidget.finishedStatements(
					statementToRemoveWidget.getData().length, // for the progress, we also count statements that didn’t change
				);
			}

			this.filesWidget.removeTagByData( title );
			this.statementsProgressBarWidget.finishedEntity();
		}

		this.statementsProgressBarWidget.finished();
		// leave the dialog open for a second so the user has a chance to see the finished progress bar
		await new Promise( resolve => setTimeout( resolve, 1000 ) );

		return true;
	};
	StatementsDialog.prototype.hasDuplicateStatementsToAdd = function () {
		return Object.values( this.hasDuplicateStatementsToAddPerProperty ).some( b => b );
	};
	StatementsDialog.prototype.hasDuplicateStatementsToRemove = function () {
		return Object.values( this.hasDuplicateStatementsToRemovePerProperty ).some( b => b );
	};
	StatementsDialog.prototype.hasStatementWithQualifiersToRemove = function () {
		return Object.values( this.hasStatementWithQualifiersToRemovePerProperty ).some( b => b );
	};
	StatementsDialog.prototype.updateCanSave = function () {
		this.actions.setAbilities( {
			save: this.filesWidget.getTitles().length &&
				(
					this.statementToAddWidgets.some(
						statementToAddWidget => statementToAddWidget.getData().length ) ||
					this.statementToRemoveWidgets.some(
						statementToRemoveWidget => statementToRemoveWidget.getData().length )
				) &&
				!this.hasDuplicateStatementsToAdd() &&
				!this.hasDuplicateStatementsToRemove() &&
				!this.hasStatementWithQualifiersToRemove(),
		} );
	};
	StatementsDialog.prototype.updateShowDuplicateStatementsToAddError = function () {
		this.duplicateStatementsToAddError.toggle( this.hasDuplicateStatementsToAdd() );
	};
	StatementsDialog.prototype.updateShowDuplicateStatementsToRemoveError = function () {
		this.duplicateStatementsToRemoveError.toggle( this.hasDuplicateStatementsToRemove() );
	};
	StatementsDialog.prototype.updateShowStatementWithQualifiersToRemoveError = function () {
		this.statementWithQualifiersToRemoveError.toggle( this.hasStatementWithQualifiersToRemove() );
	};
	StatementsDialog.prototype.getBodyHeight = function () {
		// we ceil the body height to the next multiple of 200 so it doesn’t change too often
		return this.$head.outerHeight( true ) +
			Math.max(
				400, // minimum size to start out with
				Math.ceil( this.$body.outerHeight( true ) / 200 ) * 200,
			) +
			this.$foot.outerHeight( true ) +
			50; // not sure why a bit of extra space is necessary :/
	};

	let tags = [];
	if ( mw.config.get( 'wgServer' ) === '//commons.wikimedia.org' ||
		mw.config.get( 'wgServer' ) === '//test-commons.wikimedia.org' ) {
		tags = [ 'ACDC' ];
	}

	const factory = new OO.Factory();
	factory.register( StatementsDialog );

	const windowManager = new OO.ui.WindowManager( { factory } );
	$( document.body ).append( windowManager.$element );

	// ensure default window manager for prompt, alert etc. comes after (i.e. displays above) ours,
	// even if it had already been created and attached to the DOM earlier
	OO.ui.getWindowManager().$element.insertAfter( windowManager.$element );
	// likewise for the default overlay (and we even want that after/above the default window manager)
	OO.ui.getDefaultOverlay().insertAfter( OO.ui.getWindowManager().$element );

	const portletLink = mw.util.addPortletLink( 'p-tb', '', 'AC/DC', 't-acdc' ),
		$portletLink = $( portletLink );
	$portletLink.on( 'click', () => {
		try {
			windowManager.openWindow( 'statements', { tags } );
		} catch ( e ) {
			OO.ui.alert( String( e ) );
		}
		return false;
	} );

	const startup = mw.util.getParamValue( 'acdcShow' ),
		startupPagePileId = mw.util.getParamValue( 'acdcPagePileId' );
	if ( startup || startupPagePileId ) {
		windowManager.openWindow( 'statements', { tags } );
		const statementsDialog = await windowManager.getWindow( 'statements' );
		if ( startupPagePileId ) {
			await statementsDialog.filesWidget.loadPagePile( startupPagePileId );
		}
	}

	mw.hook( 'gadget.acdc.loaded' ).fire();
}( mediaWiki, jQuery ) );