Merge "Add (hacky) check for webm audio files"
[lhc/web/wiklou.git] / resources / src / mediawiki / api / upload.js
index 8169449..219dfb8 100644 (file)
                        comment: true,
                        text: true,
                        watchlist: true,
-                       ignorewarnings: true
+                       ignorewarnings: true,
+                       chunk: true,
+                       offset: true,
+                       filesize: true,
+                       async: true
                };
 
        /**
@@ -34,7 +38,8 @@
         * @return {string}
         */
        function getFirstKey( obj ) {
-               for ( var key in obj ) {
+               var key;
+               for ( key in obj ) {
                        if ( obj.hasOwnProperty( key ) ) {
                                return key;
                        }
@@ -45,6 +50,7 @@
         * Get new iframe object for an upload.
         *
         * @private
+        * @param {string} id
         * @return {HTMLIframeElement}
         */
        function getNewIframe( id ) {
@@ -58,6 +64,8 @@
         * Shortcut for getting hidden inputs
         *
         * @private
+        * @param {string} name
+        * @param {string} val
         * @return {jQuery}
         */
        function getHiddenInput( name, val ) {
                 * @return {jQuery.Promise}
                 */
                uploadWithFormData: function ( file, data ) {
-                       var key,
+                       var key, request,
                                deferred = $.Deferred();
 
                        for ( key in data ) {
                        }
 
                        data = $.extend( {}, this.defaults.parameters, { action: 'upload' }, data );
-                       data.file = file;
+                       if ( !data.chunk ) {
+                               data.file = file;
+                       }
 
                        if ( !data.filename && !data.stash ) {
                                throw new Error( 'Filename not included in file data.' );
                        }
 
                        // Use this.postWithEditToken() or this.post()
-                       this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, {
+                       request = this[ this.needToken() ? 'postWithEditToken' : 'post' ]( data, {
                                // Use FormData (if we got here, we know that it's available)
                                contentType: 'multipart/form-data',
                                // No timeout (default from mw.Api is 30 seconds)
                                        deferred.reject( errorCode, result );
                                } );
 
-                       return deferred.promise();
+                       return deferred.promise( { abort: request.abort } );
+               },
+
+               /**
+                * Upload a file in several chunks.
+                *
+                * @param {File} file
+                * @param {Object} data Other upload options, see action=upload API docs for more
+                * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB)
+                * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1)
+                * @return {jQuery.Promise}
+                */
+               chunkedUpload: function ( file, data, chunkSize, chunkRetries ) {
+                       var start, end, promise, next, active,
+                               deferred = $.Deferred();
+
+                       chunkSize = chunkSize === undefined ? 5 * 1024 * 1024 : chunkSize;
+                       chunkRetries = chunkRetries === undefined ? 1 : chunkRetries;
+
+                       if ( !data.filename ) {
+                               throw new Error( 'Filename not included in file data.' );
+                       }
+
+                       // Submit first chunk to get the filekey
+                       active = promise = this.uploadChunk( file, data, 0, chunkSize, '', chunkRetries )
+                               .done( chunkSize >= file.size ? deferred.resolve : null )
+                               .fail( deferred.reject )
+                               .progress( deferred.notify );
+
+                       // Now iteratively submit the rest of the chunks
+                       for ( start = chunkSize; start < file.size; start += chunkSize ) {
+                               end = Math.min( start + chunkSize, file.size );
+                               next = $.Deferred();
+
+                               // We could simply chain one this.uploadChunk after another with
+                               // .then(), but then we'd hit an `Uncaught RangeError: Maximum
+                               // call stack size exceeded` at as low as 1024 calls in Firefox
+                               // 47. This'll work around it, but comes with the drawback of
+                               // having to properly relay the results to the returned promise.
+                               // eslint-disable-next-line no-loop-func
+                               promise.done( function ( start, end, next, result ) {
+                                       var filekey = result.upload.filekey;
+                                       active = this.uploadChunk( file, data, start, end, filekey, chunkRetries )
+                                               .done( end === file.size ? deferred.resolve : next.resolve )
+                                               .fail( deferred.reject )
+                                               .progress( deferred.notify );
+                               // start, end & next must be bound to closure, or they'd have
+                               // changed by the time the promises are resolved
+                               }.bind( this, start, end, next ) );
+
+                               promise = next;
+                       }
+
+                       return deferred.promise( { abort: active.abort } );
+               },
+
+               /**
+                * Uploads 1 chunk.
+                *
+                * @private
+                * @param {File} file
+                * @param {Object} data Other upload options, see action=upload API docs for more
+                * @param {number} start Chunk start position
+                * @param {number} end Chunk end position
+                * @param {string} [filekey] File key, for follow-up chunks
+                * @param {number} [retries] Amount of times to retry request
+                * @return {jQuery.Promise}
+                */
+               uploadChunk: function ( file, data, start, end, filekey, retries ) {
+                       var upload, retry,
+                               api = this,
+                               chunk = this.slice( file, start, end );
+
+                       // When uploading in chunks, we're going to be issuing a lot more
+                       // requests and there's always a chance of 1 getting dropped.
+                       // In such case, it could be useful to try again: a network hickup
+                       // doesn't necessarily have to result in upload failure...
+                       retries = retries === undefined ? 1 : retries;
+                       retry = function ( code, result ) {
+                               var deferred = $.Deferred(),
+                                       callback = function () {
+                                               api.uploadChunk( file, data, start, end, filekey, retries - 1 )
+                                                       .then( deferred.resolve, deferred.reject );
+                                       };
+
+                               // Don't retry if the request failed because we aborted it (or
+                               // if it's another kind of request failure)
+                               if ( code !== 'http' || result.textStatus === 'abort' ) {
+                                       return deferred.reject( code, result );
+                               }
+
+                               setTimeout( callback, 1000 );
+                               return deferred.promise();
+                       };
+
+                       data.filesize = file.size;
+                       data.chunk = chunk;
+                       data.offset = start;
+
+                       // filekey must only be added when uploading follow-up chunks; the
+                       // first chunk should never have a filekey (it'll be generated)
+                       if ( filekey && start !== 0 ) {
+                               data.filekey = filekey;
+                       }
+
+                       upload = this.uploadWithFormData( file, data );
+                       return upload.then(
+                                       null,
+                                       // If the call fails, we may want to try again...
+                                       retries === 0 ? null : retry,
+                                       function ( fraction ) {
+                                               // Since we're only uploading small parts of a file, we
+                                               // need to adjust the reported progress to reflect where
+                                               // we actually are in the combined upload
+                                               return ( start + fraction * ( end - start ) ) / file.size;
+                                       }
+                               ).promise( { abort: upload.abort } );
+               },
+
+               /**
+                * Slice a chunk out of a File object.
+                *
+                * @private
+                * @param {File} file
+                * @param {number} start
+                * @param {number} stop
+                * @return {Blob}
+                */
+               slice: function ( file, start, stop ) {
+                       if ( file.mozSlice ) {
+                               // FF <= 12
+                               return file.mozSlice( start, stop, file.type );
+                       } else if ( file.webkitSlice ) {
+                               // Chrome <= 20
+                               return file.webkitSlice( start, stop, file.type );
+                       } else {
+                               // On really old browser versions (before slice was prefixed),
+                               // slice() would take (start, length) instead of (start, end)
+                               // We'll ignore that here...
+                               return file.slice( start, stop, file.type );
+                       }
+               },
+
+               /**
+                * This function will handle how uploads to stash (via uploadToStash or
+                * chunkedUploadToStash) are resolved/rejected.
+                *
+                * After a successful stash, it'll resolve with a callback which, when
+                * called, will finalize the upload in stash (with the given data, or
+                * with additional/conflicting data)
+                *
+                * A failed stash can still be recovered from as long as 'filekey' is
+                * present. In that case, it'll also resolve with the callback to
+                * finalize the upload (all warnings are then ignored.)
+                * Otherwise, it'll just reject as you'd expect, with code & result.
+                *
+                * @private
+                * @param {jQuery.Promise} uploadPromise
+                * @param {Object} data
+                * @return {jQuery.Promise}
+                * @return {Function} return.finishUpload Call this function to finish the upload.
+                * @return {Object} return.finishUpload.data Additional data for the upload.
+                * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
+                * @return {Object} return.finishUpload.return.data API return value for the final upload
+                */
+               finishUploadToStash: function ( uploadPromise, data ) {
+                       var filekey,
+                               api = this;
+
+                       function finishUpload( moreData ) {
+                               return api.uploadFromStash( filekey, $.extend( data, moreData ) );
+                       }
+
+                       return uploadPromise.then(
+                               function ( result ) {
+                                       filekey = result.upload.filekey;
+                                       return finishUpload;
+                               },
+                               function ( errorCode, result ) {
+                                       if ( result && result.upload && result.upload.filekey ) {
+                                               // Ignore any warnings if 'filekey' was returned, that's all we care about
+                                               filekey = result.upload.filekey;
+                                               return $.Deferred().resolve( finishUpload );
+                                       }
+                                       return $.Deferred().reject( errorCode, result );
+                               }
+                       );
                },
 
                /**
                 * @param {File|HTMLInputElement} file
                 * @param {Object} [data]
                 * @return {jQuery.Promise}
-                * @return {Function} return.finishStashUpload Call this function to finish the upload.
-                * @return {Object} return.finishStashUpload.data Additional data for the upload.
-                * @return {jQuery.Promise} return.finishStashUpload.return API promise for the final upload
-                * @return {Object} return.finishStashUpload.return.data API return value for the final upload
+                * @return {Function} return.finishUpload Call this function to finish the upload.
+                * @return {Object} return.finishUpload.data Additional data for the upload.
+                * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
+                * @return {Object} return.finishUpload.return.data API return value for the final upload
                 */
                uploadToStash: function ( file, data ) {
-                       var filekey,
-                               api = this;
+                       var promise;
 
                        if ( !data.filename ) {
                                throw new Error( 'Filename not included in file data.' );
                        }
 
-                       function finishUpload( moreData ) {
-                               return api.uploadFromStash( filekey, $.extend( data, moreData ) );
+                       promise = this.upload( file, { stash: true, filename: data.filename } );
+
+                       return this.finishUploadToStash( promise, data );
+               },
+
+               /**
+                * Upload a file to the stash, in chunks.
+                *
+                * This function will return a promise, which when resolved, will pass back a function
+                * to finish the stash upload.
+                *
+                * @see #method-uploadToStash
+                * @param {File|HTMLInputElement} file
+                * @param {Object} [data]
+                * @param {number} [chunkSize] Size (in bytes) per chunk (default: 5MB)
+                * @param {number} [chunkRetries] Amount of times to retry a failed chunk (default: 1)
+                * @return {jQuery.Promise}
+                * @return {Function} return.finishUpload Call this function to finish the upload.
+                * @return {Object} return.finishUpload.data Additional data for the upload.
+                * @return {jQuery.Promise} return.finishUpload.return API promise for the final upload
+                * @return {Object} return.finishUpload.return.data API return value for the final upload
+                */
+               chunkedUploadToStash: function ( file, data, chunkSize, chunkRetries ) {
+                       var promise;
+
+                       if ( !data.filename ) {
+                               throw new Error( 'Filename not included in file data.' );
                        }
 
-                       return this.upload( file, { stash: true, filename: data.filename } ).then(
-                               function ( result ) {
-                                       filekey = result.upload.filekey;
-                                       return finishUpload;
-                               },
-                               function ( errorCode, result ) {
-                                       if ( result && result.upload && result.upload.filekey ) {
-                                               // Ignore any warnings if 'filekey' was returned, that's all we care about
-                                               filekey = result.upload.filekey;
-                                               return $.Deferred().resolve( finishUpload );
-                                       }
-                                       return $.Deferred().reject( errorCode, result );
-                               }
+                       promise = this.chunkedUpload(
+                               file,
+                               { stash: true, filename: data.filename },
+                               chunkSize,
+                               chunkRetries
                        );
+
+                       return this.finishUploadToStash( promise, data );
                },
 
                /**
                 *
                 * @param {string} filekey
                 * @param {Object} data
+                * @return {jQuery.Promise}
                 */
                uploadFromStash: function ( filekey, data ) {
                        data.filekey = filekey;