MediaWiki:Gadget-VEBulkUpload.js

From RECESSIM, A Reverse Engineering Community
Jump to navigation Jump to search

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

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * VEBulkUpload — bulk file upload for VisualEditor.
 *
 * Drag a group of files anywhere onto the page you are visually editing (or use
 * Insert → "Upload files"), give them a shared description/categories, and they
 * are uploaded and inserted as a <gallery>, as thumbnails, or as a link list.
 *
 * Single-file drops are deliberately NOT intercepted — those keep going to
 * VisualEditor's own media dialog (Extension:EnhancedUpload).
 *
 * Loaded into VisualEditor through $wgVisualEditorPluginModules, so this file
 * executes before the editor toolbar is built.
 *
 * Documentation: [[Help:Uploading files]]
 * Maintainer note: only three VisualEditor APIs are used — ve.ui.toolFactory,
 * ve.ui.dataTransferHandlerFactory/DataTransferItem, and the ce.Surface drop
 * events. Re-check those after a MediaWiki upgrade.
 */
( function () {
	'use strict';

	if ( typeof ve === 'undefined' || !ve.ui || !ve.ui.FragmentDialog || !ve.ui.FragmentWindowTool ) {
		// Not inside VisualEditor — nothing to do.
		return;
	}

	var IMAGE_EXTENSIONS = [ 'jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'tif', 'tiff' ],
		TITLE_QUERY_CHUNK = 45,
		api = null,
		allowedExtensionsPromise = null;

	function getApi() {
		if ( !api ) {
			api = new mw.Api();
		}
		return api;
	}

	function getExtension( name ) {
		var parts = String( name ).split( '.' );
		return parts.length > 1 ? parts.pop().toLowerCase() : '';
	}

	function isImageName( name ) {
		return IMAGE_EXTENSIONS.indexOf( getExtension( name ) ) !== -1;
	}

	function stripExtension( name ) {
		return String( name ).replace( /\.[^.]+$/, '' );
	}

	function captionFor( name ) {
		return stripExtension( name ).replace( /[_-]+/g, ' ' ).replace( /\s+/g, ' ' ).trim();
	}

	/**
	 * Turn a local filename into a valid File: page name.
	 *
	 * @param {string} name
	 * @return {string}
	 */
	function normaliseFileName( name ) {
		var title = mw.Title.newFromFileName( name );
		return title ? title.getMainText() : String( name ).replace( /[#<>[\]|{}]/g, '' );
	}

	/**
	 * Extensions this wiki accepts, from siteinfo. Cached for the session.
	 * Fails open (empty list = accept everything) so a flaky API can't block uploads.
	 *
	 * @return {jQuery.Promise} Promise resolving with an array of lowercase extensions
	 */
	function getAllowedExtensions() {
		if ( !allowedExtensionsPromise ) {
			allowedExtensionsPromise = getApi().get( {
				action: 'query',
				meta: 'siteinfo',
				siprop: 'fileextensions',
				formatversion: 2
			} ).then( function ( data ) {
				return ( data.query.fileextensions || [] ).map( function ( e ) {
					return String( e.ext ).toLowerCase();
				} );
			}, function () {
				return [];
			} );
		}
		return allowedExtensionsPromise;
	}

	function apiErrorMessage( code, result ) {
		if ( result && result.errors && result.errors[ 0 ] ) {
			return result.errors[ 0 ].html || result.errors[ 0 ].text || code;
		}
		if ( result && result.error && result.error.info ) {
			return result.error.info;
		}
		return String( code || 'unknown error' );
	}

	/* ---------------------------------------------------------------- rows -- */

	/**
	 * One queued file in the dialog.
	 *
	 * @class
	 * @param {File} file
	 * @param {Object} [config]
	 */
	function FileRow( file, config ) {
		var row = this;

		FileRow.super.call( this, config || {} );

		this.file = file;
		this.exists = false;
		this.done = false;
		this.uploadedName = null;
		this.error = null;

		this.nameInput = new OO.ui.TextInputWidget( {
			value: normaliseFileName( file.name ),
			classes: [ 've-bulkupload-row-name' ]
		} );
		this.nameInput.connect( this, { change: 'onNameChange' } );

		this.policyInput = new OO.ui.DropdownInputWidget( {
			options: [
				{ data: 'rename', label: 'Upload under a new name' },
				{ data: 'existing', label: 'Use the file already on the wiki' },
				{ data: 'overwrite', label: 'Overwrite the existing file' },
				{ data: 'skip', label: 'Skip this file' }
			],
			value: 'rename',
			classes: [ 've-bulkupload-row-policy' ]
		} );
		this.policyInput.toggle( false );

		this.statusLabel = new OO.ui.LabelWidget( {
			label: '',
			classes: [ 've-bulkupload-row-status' ]
		} );

		this.removeButton = new OO.ui.ButtonWidget( {
			icon: 'trash',
			framed: false,
			title: 'Remove from this upload',
			classes: [ 've-bulkupload-row-remove' ]
		} );
		this.removeButton.connect( this, { click: 'onRemove' } );

		this.$thumb = $( '<div>' ).addClass( 've-bulkupload-row-thumb' );
		if ( isImageName( file.name ) && window.URL && URL.createObjectURL ) {
			this.objectUrl = URL.createObjectURL( file );
			this.$thumb.append( $( '<img>' ).attr( 'src', this.objectUrl ).on( 'error', function () {
				row.$thumb.empty().text( getExtension( file.name ).toUpperCase() );
			} ) );
		} else {
			this.$thumb.addClass( 've-bulkupload-row-thumb-generic' )
				.text( getExtension( file.name ).toUpperCase() || '?' );
		}

		this.progressBar = new OO.ui.ProgressBarWidget( { progress: 0 } );
		this.progressBar.toggle( false );

		this.$element
			.addClass( 've-bulkupload-row' )
			.append(
				this.$thumb,
				$( '<div>' ).addClass( 've-bulkupload-row-body' ).append(
					this.nameInput.$element,
					$( '<div>' ).addClass( 've-bulkupload-row-meta' ).append(
						$( '<span>' ).addClass( 've-bulkupload-row-size' )
							.text( formatSize( file.size ) ),
						this.statusLabel.$element,
						this.policyInput.$element
					),
					this.progressBar.$element
				),
				this.removeButton.$element
			);
	}
	OO.inheritClass( FileRow, OO.ui.Widget );

	function formatSize( bytes ) {
		if ( bytes > 1048576 ) {
			return ( bytes / 1048576 ).toFixed( 1 ) + ' MB';
		}
		return Math.max( 1, Math.round( bytes / 1024 ) ) + ' KB';
	}

	FileRow.prototype.onRemove = function () {
		this.emit( 'remove', this );
	};

	FileRow.prototype.onNameChange = function () {
		this.emit( 'nameChange', this );
	};

	FileRow.prototype.getFileName = function () {
		return normaliseFileName( this.nameInput.getValue().trim() );
	};

	FileRow.prototype.getPolicy = function () {
		return this.exists ? this.policyInput.getValue() : 'rename';
	};

	FileRow.prototype.setExists = function ( exists ) {
		this.exists = !!exists;
		this.policyInput.toggle( this.exists );
		this.setStatus( this.exists ? 'A file with this name already exists.' : '', this.exists ? 'warning' : null );
	};

	FileRow.prototype.setStatus = function ( text, kind ) {
		this.statusLabel.setLabel( text || '' );
		this.statusLabel.$element
			.toggleClass( 've-bulkupload-status-warning', kind === 'warning' )
			.toggleClass( 've-bulkupload-status-error', kind === 'error' )
			.toggleClass( 've-bulkupload-status-done', kind === 'done' );
	};

	FileRow.prototype.setProgress = function ( fraction ) {
		this.progressBar.toggle( true );
		this.progressBar.setProgress( Math.round( fraction * 100 ) );
	};

	FileRow.prototype.setBusy = function ( busy ) {
		this.nameInput.setDisabled( busy );
		this.policyInput.setDisabled( busy );
		this.removeButton.setDisabled( busy );
	};

	FileRow.prototype.destroy = function () {
		if ( this.objectUrl ) {
			URL.revokeObjectURL( this.objectUrl );
			this.objectUrl = null;
		}
	};

	/* -------------------------------------------------------------- dialog -- */

	/**
	 * @class
	 * @param {Object} [config]
	 */
	function BulkUploadDialog( config ) {
		BulkUploadDialog.super.call( this, config );
	}
	OO.inheritClass( BulkUploadDialog, ve.ui.FragmentDialog );

	BulkUploadDialog.static.name = 'veBulkUpload';
	BulkUploadDialog.static.title = 'Upload files';
	BulkUploadDialog.static.size = 'large';
	BulkUploadDialog.static.actions = [
		{
			action: 'upload',
			label: 'Upload and insert',
			flags: [ 'primary', 'progressive' ],
			modes: 'edit'
		},
		{
			label: 'Cancel',
			flags: [ 'safe', 'close' ],
			modes: 'edit'
		},
		{
			action: 'abort',
			label: 'Stop',
			flags: [ 'safe' ],
			modes: 'uploading'
		},
		{
			action: 'finish',
			label: 'Close',
			flags: [ 'primary', 'progressive' ],
			modes: 'result'
		}
	];

	BulkUploadDialog.prototype.initialize = function () {
		var dialog = this;

		BulkUploadDialog.super.prototype.initialize.call( this );

		this.panel = new OO.ui.PanelLayout( { padded: true, expanded: false } );

		// Drop zone / file picker
		this.$fileInput = $( '<input>' )
			.attr( { type: 'file', multiple: 'multiple' } )
			.addClass( 've-bulkupload-fileinput' )
			.on( 'change', function () {
				dialog.addFiles( Array.prototype.slice.call( this.files ) );
				this.value = '';
			} );

		this.$dropZone = $( '<div>' )
			.addClass( 've-bulkupload-dropzone' )
			.append(
				$( '<p>' ).text( 'Drop files here, or' ),
				new OO.ui.ButtonWidget( {
					label: 'Choose files',
					icon: 'upload',
					flags: [ 'progressive' ]
				} ).on( 'click', function () {
					dialog.$fileInput.trigger( 'click' );
				} ).$element,
				this.$fileInput
			)
			.on( 'dragover dragenter', function ( e ) {
				e.preventDefault();
				e.stopPropagation();
				$( this ).addClass( 've-bulkupload-dropzone-active' );
			} )
			.on( 'dragleave dragend', function () {
				$( this ).removeClass( 've-bulkupload-dropzone-active' );
			} )
			.on( 'drop', function ( e ) {
				e.preventDefault();
				e.stopPropagation();
				$( this ).removeClass( 've-bulkupload-dropzone-active' );
				var files = e.originalEvent.dataTransfer && e.originalEvent.dataTransfer.files;
				if ( files && files.length ) {
					dialog.addFiles( Array.prototype.slice.call( files ) );
				}
			} );

		this.$fileList = $( '<div>' ).addClass( 've-bulkupload-files' );

		// Shared options
		this.insertAsInput = new OO.ui.RadioSelectInputWidget( {
			options: [
				{ data: 'gallery', label: 'A gallery' },
				{ data: 'thumbs', label: 'Individual thumbnails' },
				{ data: 'links', label: 'A list of file links' }
			],
			value: 'gallery'
		} );
		this.insertAsInput.connect( this, { change: 'onInsertAsChange' } );

		this.heightsInput = new OO.ui.NumberInputWidget( { value: 180, min: 60, max: 500 } );
		this.captionsInput = new OO.ui.CheckboxInputWidget( { selected: true } );
		this.descriptionInput = new OO.ui.MultilineTextInputWidget( {
			rows: 2,
			placeholder: 'Shared description saved on every file page (optional)'
		} );
		this.categoriesInput = new mw.widgets.CategoryMultiselectWidget();

		this.galleryField = new OO.ui.FieldLayout( this.heightsInput, {
			label: 'Gallery image height (px)',
			align: 'top'
		} );

		this.optionsFieldset = new OO.ui.FieldsetLayout( { label: 'Insert as' } );
		this.optionsFieldset.addItems( [
			new OO.ui.FieldLayout( this.insertAsInput, { align: 'top' } ),
			this.galleryField,
			new OO.ui.FieldLayout( this.captionsInput, {
				label: 'Use file names as captions',
				align: 'inline'
			} ),
			new OO.ui.FieldLayout( this.descriptionInput, {
				label: 'Description',
				align: 'top'
			} ),
			new OO.ui.FieldLayout( this.categoriesInput, {
				label: 'Categories for every file',
				align: 'top'
			} )
		] );

		// Progress + results
		this.overallProgress = new OO.ui.ProgressBarWidget( { progress: 0 } );
		this.overallProgress.toggle( false );
		this.statusLabel = new OO.ui.LabelWidget( {
			label: '',
			classes: [ 've-bulkupload-status' ]
		} );
		this.$results = $( '<div>' ).addClass( 've-bulkupload-results' ).hide();

		this.panel.$element.append(
			this.$dropZone,
			this.$fileList,
			this.optionsFieldset.$element,
			this.overallProgress.$element,
			this.statusLabel.$element,
			this.$results
		);
		this.$body.append( this.panel.$element );
	};

	BulkUploadDialog.prototype.onInsertAsChange = function () {
		this.galleryField.toggle( this.insertAsInput.getValue() === 'gallery' );
	};

	BulkUploadDialog.prototype.getSetupProcess = function ( data ) {
		data = data || {};
		return BulkUploadDialog.super.prototype.getSetupProcess.call( this, data )
			.next( function () {
				this.rows = [];
				this.results = [];
				this.failures = [];
				this.aborted = false;
				this.currentUpload = null;

				this.$fileList.empty();
				this.$results.empty().hide();
				this.overallProgress.toggle( false ).setProgress( 0 );
				this.statusLabel.setLabel( '' );
				this.descriptionInput.setValue( '' );
				this.categoriesInput.setValue( [] );
				this.insertAsInput.setValue( 'gallery' );
				this.captionsInput.setSelected( true );
				this.onInsertAsChange();
				this.actions.setMode( 'edit' );

				if ( data.files && data.files.length ) {
					this.addFiles( data.files );
				}
				this.updateActions();
			}, this );
	};

	BulkUploadDialog.prototype.getTeardownProcess = function ( data ) {
		return BulkUploadDialog.super.prototype.getTeardownProcess.call( this, data )
			.first( function () {
				this.rows.forEach( function ( row ) {
					row.destroy();
				} );
				this.rows = [];
			}, this );
	};

	BulkUploadDialog.prototype.addFiles = function ( files ) {
		var dialog = this;

		getAllowedExtensions().then( function ( allowed ) {
			var rejected = [];

			files.forEach( function ( file ) {
				var ext = getExtension( file.name );
				if ( allowed.length && allowed.indexOf( ext ) === -1 ) {
					rejected.push( file.name );
					return;
				}
				var row = new FileRow( file );
				row.connect( dialog, { remove: 'onRowRemove', nameChange: 'onRowNameChange' } );
				dialog.rows.push( row );
				dialog.$fileList.append( row.$element );
			} );

			if ( rejected.length ) {
				dialog.statusLabel.setLabel(
					'Not allowed on this wiki, skipped: ' + rejected.join( ', ' )
				);
			}
			dialog.updateActions();
			dialog.checkExisting();
			dialog.updateSize();
		} );
	};

	BulkUploadDialog.prototype.onRowRemove = function ( row ) {
		var index = this.rows.indexOf( row );
		if ( index !== -1 ) {
			this.rows.splice( index, 1 );
			row.$element.remove();
			row.destroy();
			this.updateActions();
			this.updateSize();
		}
	};

	BulkUploadDialog.prototype.onRowNameChange = function () {
		if ( this.nameCheckTimeout ) {
			clearTimeout( this.nameCheckTimeout );
		}
		this.nameCheckTimeout = setTimeout( this.checkExisting.bind( this ), 500 );
	};

	BulkUploadDialog.prototype.updateActions = function () {
		this.actions.setAbilities( { upload: this.rows.length > 0 } );
	};

	/**
	 * Ask the wiki which of the queued names are already taken, so conflicts are
	 * resolved before a long upload run rather than in the middle of one.
	 */
	BulkUploadDialog.prototype.checkExisting = function () {
		var dialog = this,
			rows = this.rows.slice(),
			titles = rows.map( function ( row ) {
				return 'File:' + row.getFileName();
			} ),
			chunks = [],
			i;

		if ( !titles.length ) {
			return;
		}
		for ( i = 0; i < titles.length; i += TITLE_QUERY_CHUNK ) {
			chunks.push( titles.slice( i, i + TITLE_QUERY_CHUNK ) );
		}

		$.when.apply( $, chunks.map( function ( chunk ) {
			return getApi().get( {
				action: 'query',
				titles: chunk,
				formatversion: 2
			} );
		} ) ).then( function () {
			var existing = {};
			Array.prototype.slice.call( arguments ).forEach( function ( response ) {
				var data = Array.isArray( response ) ? response[ 0 ] : response;
				( ( data && data.query && data.query.pages ) || [] ).forEach( function ( page ) {
					if ( !page.missing ) {
						existing[ page.title ] = true;
					}
				} );
			} );
			rows.forEach( function ( row ) {
				if ( dialog.rows.indexOf( row ) !== -1 && !row.done ) {
					row.setExists( !!existing[ 'File:' + row.getFileName() ] );
				}
			} );
			dialog.updateSize();
		} );
	};

	BulkUploadDialog.prototype.getActionProcess = function ( action ) {
		var dialog = this;

		if ( action === 'upload' ) {
			return new OO.ui.Process( function () {
				var error = dialog.validate();
				if ( error ) {
					return $.Deferred().reject( new OO.ui.Error( error, { recoverable: true } ) ).promise();
				}
				dialog.actions.setMode( 'uploading' );
				dialog.$dropZone.hide();
				dialog.optionsFieldset.$element.hide();
				dialog.overallProgress.toggle( true );
				dialog.rows.forEach( function ( row ) {
					row.setBusy( true );
				} );
				return dialog.runUploads().then( function () {
					return dialog.finish();
				} );
			} );
		}
		if ( action === 'abort' ) {
			return new OO.ui.Process( function () {
				dialog.aborted = true;
				dialog.statusLabel.setLabel( 'Stopping…' );
				if ( dialog.currentUpload && dialog.currentUpload.abort ) {
					dialog.currentUpload.abort();
				}
			} );
		}
		if ( action === 'finish' ) {
			return new OO.ui.Process( function () {
				dialog.close( { action: 'finish' } );
			} );
		}
		return BulkUploadDialog.super.prototype.getActionProcess.call( this, action );
	};

	BulkUploadDialog.prototype.validate = function () {
		var seen = {},
			problem = null;

		if ( !this.rows.length ) {
			return 'Add at least one file.';
		}
		this.rows.forEach( function ( row ) {
			var name = row.getFileName();
			if ( !name || name === '.' + getExtension( name ) ) {
				problem = problem || 'Every file needs a name.';
				return;
			}
			if ( !getExtension( name ) ) {
				problem = problem || 'File names must keep their extension (' + name + ').';
				return;
			}
			if ( seen[ name ] && row.getPolicy() !== 'skip' ) {
				problem = problem || 'Two files are queued as "' + name + '". Rename one of them.';
			}
			seen[ name ] = true;
		} );
		return problem;
	};

	/** Upload every queued file, one at a time. Never rejects. */
	BulkUploadDialog.prototype.runUploads = function () {
		var dialog = this,
			queue = this.rows.filter( function ( row ) {
				return row.getPolicy() !== 'skip';
			} ),
			total = queue.length,
			index = 0;

		function next() {
			var row;
			if ( dialog.aborted || index >= total ) {
				return $.Deferred().resolve().promise();
			}
			row = queue[ index ];
			dialog.statusLabel.setLabel(
				'Uploading ' + ( index + 1 ) + ' of ' + total + ': ' + row.getFileName()
			);
			return dialog.uploadRow( row, index, total ).then( function () {
				index++;
				dialog.overallProgress.setProgress( Math.round( ( index / total ) * 100 ) );
				return next();
			} );
		}

		return next();
	};

	/** Upload a single row. Always resolves; failures are recorded on the row. */
	BulkUploadDialog.prototype.uploadRow = function ( row, index, total ) {
		var dialog = this,
			deferred = $.Deferred(),
			name = row.getFileName(),
			policy = row.getPolicy(),
			useCaptions = this.captionsInput.isSelected(),
			caption = useCaptions ? captionFor( row.file.name ) : '';

		function record( finalName, note ) {
			row.done = true;
			row.uploadedName = finalName;
			row.setProgress( 1 );
			row.setStatus( note || 'Uploaded.', 'done' );
			dialog.results.push( {
				name: finalName,
				caption: caption,
				isImage: isImageName( finalName )
			} );
			deferred.resolve();
		}

		function fail( message ) {
			row.setStatus( message, 'error' );
			dialog.failures.push( { name: name, message: message } );
			deferred.resolve();
		}

		if ( policy === 'existing' ) {
			record( name, 'Used the file already on the wiki.' );
			return deferred.promise();
		}

		if ( policy === 'rename' && row.exists ) {
			name = this.uniqueName( name );
			row.nameInput.setValue( name );
		}

		this.currentUpload = getApi().upload( row.file, {
			filename: name,
			comment: 'Bulk upload from the visual editor',
			text: this.buildFilePageText( caption ),
			ignorewarnings: policy === 'overwrite' ? 1 : undefined
		} );

		this.currentUpload.progress( function ( fraction ) {
			row.setProgress( fraction );
			dialog.overallProgress.setProgress(
				Math.round( ( ( index + fraction ) / total ) * 100 )
			);
		} ).done( function ( result ) {
			var uploaded = result && result.upload;
			record( ( uploaded && uploaded.filename ) || name );
		} ).fail( function ( code, result ) {
			var warnings = result && result.upload && result.upload.warnings;

			if ( dialog.aborted ) {
				row.setStatus( 'Cancelled.', 'warning' );
				deferred.resolve();
				return;
			}
			if ( warnings && warnings.duplicate && warnings.duplicate.length ) {
				// Byte-identical file already on the wiki — use it instead of
				// storing a second copy.
				record( warnings.duplicate[ 0 ], 'Identical file already on the wiki — used that one.' );
				return;
			}
			if ( warnings && ( warnings.exists || warnings[ 'exists-normalized' ] ) ) {
				fail( 'A file called "' + name + '" already exists. Choose a different name, or "Overwrite".' );
				return;
			}
			if ( warnings && warnings[ 'was-deleted' ] ) {
				fail( 'A file with this name was deleted before. Rename it, or upload it via Special:Upload.' );
				return;
			}
			if ( warnings ) {
				fail( 'Upload warning: ' + Object.keys( warnings ).join( ', ' ) );
				return;
			}
			fail( apiErrorMessage( code, result ) );
		} ).always( function () {
			dialog.currentUpload = null;
		} );

		return deferred.promise();
	};

	/** Find a free "Name-1.jpg" style variant. */
	BulkUploadDialog.prototype.uniqueName = function ( name ) {
		var base = stripExtension( name ),
			ext = getExtension( name ),
			taken = {},
			candidate,
			i = 1;

		this.rows.forEach( function ( row ) {
			if ( row.uploadedName ) {
				taken[ row.uploadedName ] = true;
			}
		} );
		this.results.forEach( function ( result ) {
			taken[ result.name ] = true;
		} );

		do {
			candidate = base + '-' + i + '.' + ext;
			i++;
		} while ( taken[ candidate ] && i < 1000 );

		return candidate;
	};

	BulkUploadDialog.prototype.buildFilePageText = function ( caption ) {
		var parts = [],
			description = this.descriptionInput.getValue().trim(),
			categories = this.categoriesInput.getValue();

		if ( description ) {
			parts.push( description );
		} else if ( caption ) {
			parts.push( caption );
		}
		if ( categories.length ) {
			parts.push( categories.map( function ( category ) {
				return '[[Category:' + category + ']]';
			} ).join( '\n' ) );
		}
		return parts.join( '\n\n' );
	};

	/** Build the wikitext for everything that uploaded successfully. */
	BulkUploadDialog.prototype.buildWikitext = function () {
		var insertAs = this.insertAsInput.getValue(),
			heights = parseInt( this.heightsInput.getValue(), 10 ) || 180,
			images = this.results.filter( function ( r ) {
				return r.isImage;
			} ),
			others = this.results.filter( function ( r ) {
				return !r.isImage;
			} ),
			parts = [];

		function link( result ) {
			return '* [[Media:' + result.name + '|' + ( result.caption || result.name ) + ']]';
		}

		if ( images.length ) {
			if ( insertAs === 'gallery' ) {
				parts.push(
					'<gallery mode="packed" heights="' + heights + '">\n' +
					images.map( function ( result ) {
						return 'File:' + result.name + ( result.caption ? '|' + result.caption : '' );
					} ).join( '\n' ) +
					'\n</gallery>'
				);
			} else if ( insertAs === 'thumbs' ) {
				parts.push( images.map( function ( result ) {
					return '[[File:' + result.name + '|thumb|' + ( result.caption || '' ) + ']]';
				} ).join( '\n' ) );
			} else {
				parts.push( images.map( link ).join( '\n' ) );
			}
		}
		if ( others.length ) {
			parts.push( others.map( link ).join( '\n' ) );
		}
		return parts.join( '\n\n' );
	};

	/**
	 * Insert wikitext at the target fragment, reusing VisualEditor's own
	 * wikitext → document conversion (same path as pasting wikitext).
	 *
	 * @param {string} wikitext
	 * @return {jQuery.Promise}
	 */
	BulkUploadDialog.prototype.insertWikitext = function ( wikitext ) {
		var fragment = this.getFragment(),
			surface = ( this.manager && this.manager.getSurface && this.manager.getSurface() ) ||
				( ve.init.target && ve.init.target.getSurface() ),
			item;

		if ( !surface || !fragment ) {
			return $.Deferred().reject( 'no surface' ).promise();
		}

		item = ve.ui.DataTransferItem.static.newFromString( wikitext, 'text/plain' );
		return ve.ui.dataTransferHandlerFactory.create( 'wikitextString', surface, item )
			.getInsertableData()
			.then( function ( docOrData ) {
				var target = fragment.collapseToEnd();
				if ( docOrData instanceof ve.dm.Document ) {
					target.insertDocument( docOrData );
				} else {
					target.insertContent( docOrData );
				}
				target.collapseToEnd().select();
			} );
	};

	/** Insert what succeeded, then close or report. */
	BulkUploadDialog.prototype.finish = function () {
		var dialog = this,
			wikitext;

		this.overallProgress.setProgress( 100 );

		if ( this.aborted ) {
			this.statusLabel.setLabel( 'Upload stopped — nothing was inserted.' );
			this.showResults( true );
			return $.Deferred().resolve().promise();
		}
		if ( !this.results.length ) {
			this.statusLabel.setLabel( 'Nothing was uploaded.' );
			this.showResults( false );
			return $.Deferred().resolve().promise();
		}

		this.statusLabel.setLabel( 'Inserting…' );
		wikitext = this.buildWikitext();

		return this.insertWikitext( wikitext ).then( function () {
			if ( dialog.failures.length ) {
				dialog.statusLabel.setLabel( 'Inserted, but some files failed.' );
				dialog.showResults( false );
				return;
			}
			dialog.close( { action: 'inserted' } );
		}, function () {
			// Insertion failed — the files are uploaded, so hand over the wikitext.
			dialog.statusLabel.setLabel( 'The files uploaded, but could not be inserted automatically.' );
			dialog.showResults( false, wikitext );
		} );
	};

	BulkUploadDialog.prototype.showResults = function ( aborted, wikitext ) {
		var $list = $( '<ul>' );

		this.actions.setMode( 'result' );
		this.$results.empty().show();

		if ( this.results.length ) {
			this.$results.append( $( '<p>' ).text(
				aborted ?
					'Uploaded before stopping (not inserted — you can find them under Special:NewFiles):' :
					'Uploaded:'
			) );
			this.results.forEach( function ( result ) {
				$list.append( $( '<li>' ).append(
					$( '<a>' )
						.attr( { href: mw.util.getUrl( 'File:' + result.name ), target: '_blank' } )
						.text( result.name )
				) );
			} );
			this.$results.append( $list );
		}

		if ( this.failures.length ) {
			this.$results.append( $( '<p>' ).addClass( 've-bulkupload-status-error' ).text( 'Failed:' ) );
			this.$results.append( $( '<ul>' ).append( this.failures.map( function ( failure ) {
				return $( '<li>' ).text( failure.name + ' — ' + failure.message );
			} ) ) );
		}

		if ( wikitext ) {
			this.$results.append(
				$( '<p>' ).text( 'Copy this into the page yourself:' ),
				$( '<pre>' ).text( wikitext )
			);
		}

		this.updateSize();
	};

	ve.ui.windowFactory.register( BulkUploadDialog );

	/* --------------------------------------------------------- command/tool -- */

	ve.ui.commandRegistry.register(
		new ve.ui.Command( 'bulkUpload', 'window', 'open', { args: [ 'veBulkUpload' ] } )
	);

	/**
	 * @class
	 */
	function BulkUploadTool() {
		BulkUploadTool.super.apply( this, arguments );
	}
	OO.inheritClass( BulkUploadTool, ve.ui.FragmentWindowTool );
	BulkUploadTool.static.name = 'bulkUpload';
	BulkUploadTool.static.group = 'object';
	BulkUploadTool.static.icon = 'imageGallery';
	BulkUploadTool.static.title = 'Upload files';
	BulkUploadTool.static.commandName = 'bulkUpload';
	ve.ui.toolFactory.register( BulkUploadTool );

	/* ----------------------------------------------------------- drop hook -- */

	var $toast = null,
		toastTimeout = null;

	function showToast( text ) {
		if ( !$toast ) {
			$toast = $( '<div>' ).addClass( 've-bulkupload-toast' ).appendTo( document.body );
		}
		$toast.text( text ).addClass( 've-bulkupload-toast-visible' );
		if ( toastTimeout ) {
			clearTimeout( toastTimeout );
		}
		toastTimeout = setTimeout( hideToast, 2500 );
	}

	function hideToast() {
		if ( $toast ) {
			$toast.removeClass( 've-bulkupload-toast-visible' );
		}
	}

	function countDraggedFiles( dataTransfer ) {
		if ( !dataTransfer ) {
			return 0;
		}
		if ( dataTransfer.items && dataTransfer.items.length ) {
			return Array.prototype.filter.call( dataTransfer.items, function ( item ) {
				return item.kind === 'file';
			} ).length;
		}
		return dataTransfer.files ? dataTransfer.files.length : 0;
	}

	function attachDropHandlers( surface ) {
		var view = surface.getView(),
			element = view.$element[ 0 ];

		if ( !element || element.veBulkUploadAttached ) {
			return;
		}
		element.veBulkUploadAttached = true;

		element.addEventListener( 'dragover', function ( e ) {
			var count = countDraggedFiles( e.dataTransfer );
			if ( count > 1 ) {
				showToast( 'Drop ' + count + ' files to upload them all' );
			}
		}, true );

		element.addEventListener( 'dragleave', hideToast, true );

		element.addEventListener( 'drop', function ( e ) {
			var files = e.dataTransfer && e.dataTransfer.files,
				model = surface.getModel(),
				offset,
				fragment;

			hideToast();

			// One file: leave it to VisualEditor's own media dialog.
			if ( !files || files.length < 2 || surface.getMode() !== 'visual' ) {
				return;
			}

			e.preventDefault();
			e.stopPropagation();

			offset = view.getOffsetFromEventCoords( e );
			if ( offset !== -1 && offset !== undefined ) {
				model.setLinearSelection( new ve.Range( offset ) );
			}
			fragment = model.getFragment().collapseToEnd();

			surface.execute( 'window', 'open', 'veBulkUpload', {
				files: Array.prototype.slice.call( files ),
				fragment: fragment
			} );
		}, true );
	}

	mw.hook( 've.activationComplete' ).add( function () {
		var surface = ve.init.target && ve.init.target.getSurface();
		if ( surface ) {
			attachDropHandlers( surface );
		}
	} );

}() );