5 * @classdesc Encapsulates the process of uploading a file
6 * to MediaWiki using the {@link mw.Upload upload model}.
7 * The booklet emits events that can be used to get the stashed
8 * upload and the final file. It can be extended to accept
9 * additional fields from the user for specific scenarios like
10 * for Commons, or campaigns.
14 * The {@link OO.ui.BookletLayout booklet layout} has three steps:
16 * - **Upload**: Has a {@link OO.ui.SelectFileInputWidget field} to get the file object.
18 * - **Information**: Has a {@link OO.ui.FormLayout form} to collect metadata. This can be
21 * - **Insert**: Has details on how to use the file that was uploaded.
23 * Each step has a form associated with it defined in
24 * {@link mw.Upload.BookletLayout#renderUploadForm renderUploadForm},
25 * {@link mw.Upload.BookletLayout#renderInfoForm renderInfoForm}, and
26 * {@link mw.Upload.BookletLayout#renderInsertForm renderInfoForm}. The
27 * {@link mw.Upload.BookletLayout#getFile getFile},
28 * {@link mw.Upload.BookletLayout#getFilename getFilename}, and
29 * {@link mw.Upload.BookletLayout#getText getText} methods are used to get
30 * the information filled in these forms, required to call
31 * {@link mw.Upload mw.Upload}.
35 * See the {@link mw.Upload.Dialog upload dialog}.
37 * The {@link mw.Upload.BookletLayout.event:fileUploaded fileUploaded},
38 * and {@link mw.Upload.BookletLayout.event:fileSaved fileSaved} events can
39 * be used to get details of the upload.
43 * To extend using {@link mw.Upload mw.Upload}, override
44 * {@link mw.Upload.BookletLayout#renderInfoForm renderInfoForm} to render
45 * the form required for the specific use-case. Update the
46 * {@link mw.Upload.BookletLayout#getFilename getFilename}, and
47 * {@link mw.Upload.BookletLayout#getText getText} methods to return data
48 * from your newly created form. If you added new fields you'll also have
49 * to update the {@link mw.Upload.BookletLayout#clear} method.
51 * If you plan to use a different upload model, apart from what is mentioned
52 * above, you'll also have to override the
53 * {@link mw.Upload.BookletLayout#createUpload createUpload} method to
54 * return the new model. The {@link #saveFile saveFile}, and
55 * the {@link mw.Upload.BookletLayout#uploadFile uploadFile} methods need to be
56 * overridden to use the new model and data returned from the forms.
58 * @class mw.Upload.BookletLayout
59 * @extends OO.ui.BookletLayout
62 * @description Create an instance of `mw.Upload.BookletLayout`.
63 * @param {Object} config Configuration options; see also the config parameter for the
64 * {@link mw.Upload.BookletLayout} constructor.
65 * @param {jQuery} [config.$overlay] Overlay to use for widgets in the booklet
66 * @param {string} [config.filekey] Sets the stashed file to finish uploading. Overrides most of the file selection process, and fetches a thumbnail from the server.
68 mw.Upload.BookletLayout = function ( config ) {
70 mw.Upload.BookletLayout.super.call( this, config );
72 this.$overlay = config.$overlay;
74 this.filekey = config.filekey;
76 this.renderUploadForm();
77 this.renderInfoForm();
78 this.renderInsertForm();
81 new OO.ui.PageLayout( 'initializing', {
84 content: [ new OO.ui.ProgressBarWidget( { indeterminate: true } ) ]
86 new OO.ui.PageLayout( 'upload', {
89 content: [ this.uploadForm ]
91 new OO.ui.PageLayout( 'info', {
94 content: [ this.infoForm ]
96 new OO.ui.PageLayout( 'insert', {
99 content: [ this.insertForm ]
106 OO.inheritClass( mw.Upload.BookletLayout, OO.ui.BookletLayout );
111 * Progress events for the uploaded file.
113 * @event mw.Upload.BookletLayout.fileUploadProgress
114 * @param {number} progress In percentage
115 * @param {Object} duration Duration object from `moment.duration()`
119 * The file has finished uploading.
121 * @event mw.Upload.BookletLayout.fileUploaded
125 * The file has been saved to the database.
127 * @event mw.Upload.BookletLayout.fileSaved
128 * @param {Object} imageInfo See {@link mw.Upload#getImageInfo}
132 * The upload form has changed.
134 * @event mw.Upload.BookletLayout.uploadValid
135 * @param {boolean} isValid The form is valid
139 * The info form has changed.
141 * @event mw.Upload.BookletLayout.infoValid
142 * @param {boolean} isValid The form is valid
148 * The form rendered in the first step to get the file object.
149 * Rendered in {@link mw.Upload.BookletLayout#renderUploadForm renderUploadForm}.
151 * @name mw.Upload.BookletLayout.prototype.uploadForm
152 * @type {OO.ui.FormLayout}
156 * The form rendered in the second step to get metadata.
157 * Rendered in {@link mw.Upload.BookletLayout#renderInfoForm renderInfoForm}.
159 * @name mw.Upload.BookletLayout.prototype.infoForm
160 * @type {OO.ui.FormLayout}
164 * The form rendered in the third step to show usage.
165 * Rendered in {@link mw.Upload.BookletLayout#renderInsertForm renderInsertForm}.
167 * @name mw.Upload.BookletLayout.prototype.insertForm
168 * @type {OO.ui.FormLayout}
174 * Initialize for a new upload.
176 * @return {jQuery.Promise} Promise resolved when everything is initialized
178 mw.Upload.BookletLayout.prototype.initialize = function () {
180 this.upload = this.createUpload();
182 this.setPage( 'initializing' );
184 if ( this.filekey ) {
185 this.setFilekey( this.filekey );
188 return this.upload.getApi().then(
189 // If the user can't upload anything, don't give them the option to.
190 ( api ) => api.getUserInfo().then(
192 this.setPage( 'upload' );
193 if ( userInfo.rights.indexOf( 'upload' ) === -1 ) {
194 if ( !mw.user.isNamed() ) {
195 this.getPage( 'upload' ).$element.msg( 'apierror-mustbeloggedin', mw.msg( 'action-upload' ) );
197 this.getPage( 'upload' ).$element.msg( 'apierror-permissiondenied', mw.msg( 'action-upload' ) );
200 return $.Deferred().resolve();
202 // Always resolve, never reject
204 this.setPage( 'upload' );
205 return $.Deferred().resolve();
209 this.setPage( 'upload' );
210 // eslint-disable-next-line mediawiki/msg-doc
211 this.getPage( 'upload' ).$element.msg( errorMsg );
212 return $.Deferred().resolve();
218 * Create a new upload model.
221 * @return {mw.Upload} Upload model
223 mw.Upload.BookletLayout.prototype.createUpload = function () {
224 return new mw.Upload( {
227 errorlang: mw.config.get( 'wgUserLanguage' ),
237 * Uploads the file that was added in the upload form. Uses
238 * {@link mw.Upload.BookletLayout#getFile getFile} to get the HTML5
242 * @fires mw.Upload.BookletLayout.fileUploadProgress
243 * @fires mw.Upload.BookletLayout.fileUploaded
244 * @return {jQuery.Promise}
246 mw.Upload.BookletLayout.prototype.uploadFile = function () {
247 const deferred = $.Deferred(),
248 startTime = mw.now(),
249 file = this.getFile();
251 this.setPage( 'info' );
253 if ( this.filekey ) {
254 if ( file === null ) {
255 // Someone gonna get-a hurt real bad
256 throw new Error( 'filekey not passed into file select widget, which is impossible. Quitting while we\'re behind.' );
259 // Stashed file already uploaded.
261 this.uploadPromise = deferred;
262 this.emit( 'fileUploaded' );
266 this.setFilename( file.name );
268 this.upload.setFile( file );
269 // The original file name might contain invalid characters, so use our sanitized one
270 this.upload.setFilename( this.getFilename() );
272 this.uploadPromise = this.upload.uploadToStash();
273 this.uploadPromise.then( () => {
275 this.emit( 'fileUploaded' );
277 // These errors will be thrown while the user is on the info page.
278 this.getErrorMessageForStateDetails().then( ( errorMessage ) => {
279 deferred.reject( errorMessage );
282 const elapsedTime = mw.now() - startTime,
283 estimatedTotalTime = ( 1 / progress ) * elapsedTime,
284 estimatedRemainingTime = moment.duration( estimatedTotalTime - elapsedTime );
285 this.emit( 'fileUploadProgress', progress, estimatedRemainingTime );
288 // If there is an error in uploading, come back to the upload page
289 deferred.fail( () => {
290 this.setPage( 'upload' );
297 * Saves the stash finalizes upload. Uses
298 * {@link mw.Upload.BookletLayout#getFilename getFilename}, and
299 * {@link mw.Upload.BookletLayout#getText getText} to get details from
303 * @fires mw.Upload.BookletLayout.fileSaved
304 * @return {jQuery.Promise} Rejects the promise with an
305 * {@link OO.ui.Error error}, or resolves if the upload was successful.
307 mw.Upload.BookletLayout.prototype.saveFile = function () {
308 const deferred = $.Deferred();
310 this.upload.setFilename( this.getFilename() );
311 this.upload.setText( this.getText() );
313 this.uploadPromise.then( () => {
314 this.upload.finishStashUpload().then( () => {
315 // Normalize page name and localise the 'File:' prefix
316 const name = new mw.Title( 'File:' + this.upload.getFilename() ).toString();
317 this.filenameUsageWidget.setValue( '[[' + name + ']]' );
318 this.setPage( 'insert' );
321 this.emit( 'fileSaved', this.upload.getImageInfo() );
323 this.getErrorMessageForStateDetails().then( ( errorMessage ) => {
324 deferred.reject( errorMessage );
329 return deferred.promise();
333 * Get an error message (as OO.ui.Error object) that should be displayed to the user for current
334 * state and state details.
337 * @return {jQuery.Promise|undefined} A Promise that will be resolved with an OO.ui.Error.
339 mw.Upload.BookletLayout.prototype.getErrorMessageForStateDetails = function () {
340 const state = this.upload.getState(),
341 stateDetails = this.upload.getStateDetails(),
342 warnings = stateDetails.upload && stateDetails.upload.warnings,
345 if ( state === mw.Upload.State.ERROR ) {
346 const $error = ( new mw.Api() ).getErrorMessage( stateDetails );
348 return $.Deferred().resolve( new OO.ui.Error(
350 { recoverable: false }
354 if ( state === mw.Upload.State.WARNING ) {
355 // We could get more than one of these errors, these are in order
356 // of importance. For example fixing the thumbnail like file name
357 // won't help the fact that the file already exists.
358 if ( warnings.exists !== undefined ) {
359 return $.Deferred().resolve( new OO.ui.Error(
360 $( '<p>' ).msg( 'fileexists', 'File:' + warnings.exists ),
361 { recoverable: false }
363 } else if ( warnings[ 'exists-normalized' ] !== undefined ) {
364 return $.Deferred().resolve( new OO.ui.Error(
365 $( '<p>' ).msg( 'fileexists', 'File:' + warnings[ 'exists-normalized' ] ),
366 { recoverable: false }
368 } else if ( warnings[ 'page-exists' ] !== undefined ) {
369 return $.Deferred().resolve( new OO.ui.Error(
370 $( '<p>' ).msg( 'filepageexists', 'File:' + warnings[ 'page-exists' ] ),
371 { recoverable: false }
373 } else if ( Array.isArray( warnings.duplicate ) ) {
374 warnings.duplicate.forEach( ( filename ) => {
375 const $a = $( '<a>' ).text( filename ),
376 href = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).file, filename ).getUrl( {} );
378 $a.attr( { href: href, target: '_blank' } );
379 $ul.append( $( '<li>' ).append( $a ) );
382 return $.Deferred().resolve( new OO.ui.Error(
383 $( '<p>' ).msg( 'file-exists-duplicate', warnings.duplicate.length ).append( $ul ),
384 { recoverable: false }
386 } else if ( warnings[ 'thumb-name' ] !== undefined ) {
387 return $.Deferred().resolve( new OO.ui.Error(
388 $( '<p>' ).msg( 'filename-thumb-name' ),
389 { recoverable: false }
391 } else if ( warnings[ 'bad-prefix' ] !== undefined ) {
392 return $.Deferred().resolve( new OO.ui.Error(
393 $( '<p>' ).msg( 'filename-bad-prefix', warnings[ 'bad-prefix' ] ),
394 { recoverable: false }
396 } else if ( warnings[ 'duplicate-archive' ] !== undefined ) {
397 return $.Deferred().resolve( new OO.ui.Error(
398 $( '<p>' ).msg( 'file-deleted-duplicate', 'File:' + warnings[ 'duplicate-archive' ] ),
399 { recoverable: false }
401 } else if ( warnings[ 'was-deleted' ] !== undefined ) {
402 return $.Deferred().resolve( new OO.ui.Error(
403 $( '<p>' ).msg( 'filewasdeleted', 'File:' + warnings[ 'was-deleted' ] ),
404 { recoverable: false }
406 } else if ( warnings.badfilename !== undefined ) {
407 // Change the name if the current name isn't acceptable
408 // TODO This might not really be the best place to do this
409 this.setFilename( warnings.badfilename );
410 return $.Deferred().resolve( new OO.ui.Error(
411 $( '<p>' ).msg( 'badfilename', warnings.badfilename )
414 return $.Deferred().resolve( new OO.ui.Error(
415 // Let's get all the help we can if we can't pin point the error
416 $( '<p>' ).msg( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ),
417 { recoverable: false }
426 * Renders and returns the upload form and sets the
427 * {@link mw.Upload.BookletLayout#uploadForm uploadForm} property.
430 * @return {OO.ui.FormLayout}
432 mw.Upload.BookletLayout.prototype.renderUploadForm = function () {
433 this.selectFileWidget = this.getFileWidget();
434 const fieldset = new OO.ui.FieldsetLayout();
435 fieldset.addItems( [ this.selectFileWidget ] );
436 this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
438 // Validation (if the SFW is for a stashed file, this never fires)
439 this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) );
441 this.selectFileWidget.on( 'change', () => {
442 this.updateFilePreview();
445 return this.uploadForm;
449 * Gets the widget for displaying or inputting the file to upload.
451 * @return {OO.ui.SelectFileInputWidget|mw.widgets.StashedFileWidget}
453 mw.Upload.BookletLayout.prototype.getFileWidget = function () {
454 if ( this.filekey ) {
455 return new mw.widgets.StashedFileWidget( {
456 filekey: this.filekey
460 return new OO.ui.SelectFileInputWidget( {
466 * Updates the file preview on the info form when a file is added.
470 mw.Upload.BookletLayout.prototype.updateFilePreview = function () {
471 this.selectFileWidget.loadAndGetImageUrl().done( ( url ) => {
472 this.filePreview.$element.find( 'p' ).remove();
473 this.filePreview.$element.css( 'background-image', 'url(' + url + ')' );
474 this.infoForm.$element.addClass( 'mw-upload-bookletLayout-hasThumbnail' );
476 this.filePreview.$element.find( 'p' ).remove();
477 if ( this.selectFileWidget.getValue() ) {
478 this.filePreview.$element.append(
479 $( '<p>' ).text( this.selectFileWidget.getValue().name )
482 this.filePreview.$element.css( 'background-image', '' );
483 this.infoForm.$element.removeClass( 'mw-upload-bookletLayout-hasThumbnail' );
488 * Handle change events to the upload form.
491 * @fires mw.Upload.BookletLayout.uploadValid
493 mw.Upload.BookletLayout.prototype.onUploadFormChange = function () {
494 this.emit( 'uploadValid', !!this.selectFileWidget.getValue() );
498 * Renders and returns the information form for collecting
499 * metadata and sets the {@link mw.Upload.BookletLayout#infoForm infoForm}
503 * @return {OO.ui.FormLayout}
505 mw.Upload.BookletLayout.prototype.renderInfoForm = function () {
506 this.filePreview = new OO.ui.Widget( {
507 classes: [ 'mw-upload-bookletLayout-filePreview' ]
509 this.progressBarWidget = new OO.ui.ProgressBarWidget( {
512 this.filePreview.$element.append( this.progressBarWidget.$element );
514 this.filenameWidget = new OO.ui.TextInputWidget( {
515 indicator: 'required',
519 this.descriptionWidget = new OO.ui.MultilineTextInputWidget( {
520 indicator: 'required',
526 const fieldset = new OO.ui.FieldsetLayout( {
527 label: mw.msg( 'upload-form-label-infoform-title' )
530 new OO.ui.FieldLayout( this.filenameWidget, {
531 label: mw.msg( 'upload-form-label-infoform-name' ),
533 help: mw.msg( 'upload-form-label-infoform-name-tooltip' )
535 new OO.ui.FieldLayout( this.descriptionWidget, {
536 label: mw.msg( 'upload-form-label-infoform-description' ),
538 help: mw.msg( 'upload-form-label-infoform-description-tooltip' )
541 this.infoForm = new OO.ui.FormLayout( {
542 classes: [ 'mw-upload-bookletLayout-infoForm' ],
543 items: [ this.filePreview, fieldset ]
546 this.on( 'fileUploadProgress', ( progress ) => {
547 this.progressBarWidget.setProgress( progress * 100 );
550 this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) );
551 this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) );
553 return this.infoForm;
557 * Handle change events to the info form.
560 * @fires mw.Upload.BookletLayout.infoValid
562 mw.Upload.BookletLayout.prototype.onInfoFormChange = function () {
564 this.filenameWidget.getValidity(),
565 this.descriptionWidget.getValidity()
567 this.emit( 'infoValid', true );
569 this.emit( 'infoValid', false );
574 * Renders and returns the insert form to show file usage and
575 * sets the {@link mw.Upload.BookletLayout#insertForm insertForm} property.
578 * @return {OO.ui.FormLayout}
580 mw.Upload.BookletLayout.prototype.renderInsertForm = function () {
581 this.filenameUsageWidget = new OO.ui.TextInputWidget();
582 const fieldset = new OO.ui.FieldsetLayout( {
583 label: mw.msg( 'upload-form-label-usage-title' )
586 new OO.ui.FieldLayout( this.filenameUsageWidget, {
587 label: mw.msg( 'upload-form-label-usage-filename' ),
591 this.insertForm = new OO.ui.FormLayout( { items: [ fieldset ] } );
593 return this.insertForm;
599 * Gets the file object from the
600 * {@link mw.Upload.BookletLayout#uploadForm upload form}.
603 * @return {File|null}
605 mw.Upload.BookletLayout.prototype.getFile = function () {
606 return this.selectFileWidget.getValue();
610 * Gets the file name from the
611 * {@link mw.Upload.BookletLayout#infoForm information form}.
616 mw.Upload.BookletLayout.prototype.getFilename = function () {
617 let filename = this.filenameWidget.getValue();
618 if ( this.filenameExtension ) {
619 filename += '.' + this.filenameExtension;
625 * Prefills the {@link mw.Upload.BookletLayout#infoForm information form} with the given filename.
628 * @param {string} filename
630 mw.Upload.BookletLayout.prototype.setFilename = function ( filename ) {
631 const title = mw.Title.newFromFileName( filename );
634 this.filenameWidget.setValue( title.getNameText() );
635 this.filenameExtension = mw.Title.normalizeExtension( title.getExtension() );
637 // Seems to happen for files with no extension, which should fail some checks anyway...
638 this.filenameWidget.setValue( filename );
639 this.filenameExtension = null;
644 * Gets the page text from the
645 * {@link mw.Upload.BookletLayout#infoForm information form}.
650 mw.Upload.BookletLayout.prototype.getText = function () {
651 return this.descriptionWidget.getValue();
657 * Sets the file object.
660 * @param {File|null} file File to select
662 mw.Upload.BookletLayout.prototype.setFile = function ( file ) {
663 this.selectFileWidget.setValue( [ file ] );
667 * Sets the filekey of a file already stashed on the server
668 * as the target of this upload operation.
671 * @param {string} filekey
673 mw.Upload.BookletLayout.prototype.setFilekey = function ( filekey ) {
674 this.upload.setFilekey( this.filekey );
675 this.selectFileWidget.setValue( filekey );
677 this.onUploadFormChange();
681 * Clear the values of all fields.
685 mw.Upload.BookletLayout.prototype.clear = function () {
686 this.selectFileWidget.setValue( null );
687 this.progressBarWidget.setProgress( 0 );
688 this.filenameWidget.setValue( null ).setValidityFlag( true );
689 this.descriptionWidget.setValue( null ).setValidityFlag( true );
690 this.filenameUsageWidget.setValue( null );