comment: true,
text: true,
watchlist: true,
- ignorewarnings: true
+ ignorewarnings: true,
+ chunk: true,
+ offset: true,
+ filesize: true,
+ async: true
};
/**
* @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 );
},
/**