268 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			268 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* eslint-env browser, jquery */
 | |
| /**
 | |
|  * Helper for implementing retries with backoff. Initial retry
 | |
|  * delay is 1 second, increasing by 2x (+jitter) for subsequent retries
 | |
|  *
 | |
|  * @constructor
 | |
|  */
 | |
| var RetryHandler = function () {
 | |
|   this.interval = 1000 // Start at one second
 | |
|   this.maxInterval = 60 * 1000 // Don't wait longer than a minute
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Invoke the function after waiting
 | |
|  *
 | |
|  * @param {function} fn Function to invoke
 | |
|  */
 | |
| RetryHandler.prototype.retry = function (fn) {
 | |
|   setTimeout(fn, this.interval)
 | |
|   this.interval = this.nextInterval_()
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Reset the counter (e.g. after successful request.)
 | |
|  */
 | |
| RetryHandler.prototype.reset = function () {
 | |
|   this.interval = 1000
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Calculate the next wait time.
 | |
|  * @return {number} Next wait interval, in milliseconds
 | |
|  *
 | |
|  * @private
 | |
|  */
 | |
| RetryHandler.prototype.nextInterval_ = function () {
 | |
|   var interval = this.interval * 2 + this.getRandomInt_(0, 1000)
 | |
|   return Math.min(interval, this.maxInterval)
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Get a random int in the range of min to max. Used to add jitter to wait times.
 | |
|  *
 | |
|  * @param {number} min Lower bounds
 | |
|  * @param {number} max Upper bounds
 | |
|  * @private
 | |
|  */
 | |
| RetryHandler.prototype.getRandomInt_ = function (min, max) {
 | |
|   return Math.floor(Math.random() * (max - min + 1) + min)
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Helper class for resumable uploads using XHR/CORS. Can upload any Blob-like item, whether
 | |
|  * files or in-memory constructs.
 | |
|  *
 | |
|  * @example
 | |
|  * var content = new Blob(["Hello world"], {"type": "text/plain"});
 | |
|  * var uploader = new MediaUploader({
 | |
|  *   file: content,
 | |
|  *   token: accessToken,
 | |
|  *   onComplete: function(data) { ... }
 | |
|  *   onError: function(data) { ... }
 | |
|  * });
 | |
|  * uploader.upload();
 | |
|  *
 | |
|  * @constructor
 | |
|  * @param {object} options Hash of options
 | |
|  * @param {string} options.token Access token
 | |
|  * @param {blob} options.file Blob-like item to upload
 | |
|  * @param {string} [options.fileId] ID of file if replacing
 | |
|  * @param {object} [options.params] Additional query parameters
 | |
|  * @param {string} [options.contentType] Content-type, if overriding the type of the blob.
 | |
|  * @param {object} [options.metadata] File metadata
 | |
|  * @param {function} [options.onComplete] Callback for when upload is complete
 | |
|  * @param {function} [options.onProgress] Callback for status for the in-progress upload
 | |
|  * @param {function} [options.onError] Callback if upload fails
 | |
|  */
 | |
| var MediaUploader = function (options) {
 | |
|   var noop = function () {}
 | |
|   this.file = options.file
 | |
|   this.contentType = options.contentType || this.file.type || 'application/octet-stream'
 | |
|   this.metadata = options.metadata || {
 | |
|     'title': this.file.name,
 | |
|     'mimeType': this.contentType
 | |
|   }
 | |
|   this.token = options.token
 | |
|   this.onComplete = options.onComplete || noop
 | |
|   this.onProgress = options.onProgress || noop
 | |
|   this.onError = options.onError || noop
 | |
|   this.offset = options.offset || 0
 | |
|   this.chunkSize = options.chunkSize || 0
 | |
|   this.retryHandler = new RetryHandler()
 | |
| 
 | |
|   this.url = options.url
 | |
|   if (!this.url) {
 | |
|     var params = options.params || {}
 | |
|     params.uploadType = 'resumable'
 | |
|     this.url = this.buildUrl_(options.fileId, params, options.baseUrl)
 | |
|   }
 | |
|   this.httpMethod = options.fileId ? 'PUT' : 'POST'
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Initiate the upload.
 | |
|  */
 | |
| MediaUploader.prototype.upload = function () {
 | |
|   var xhr = new XMLHttpRequest()
 | |
| 
 | |
|   xhr.open(this.httpMethod, this.url, true)
 | |
|   xhr.setRequestHeader('Authorization', 'Bearer ' + this.token)
 | |
|   xhr.setRequestHeader('Content-Type', 'application/json')
 | |
|   xhr.setRequestHeader('X-Upload-Content-Length', this.file.size)
 | |
|   xhr.setRequestHeader('X-Upload-Content-Type', this.contentType)
 | |
| 
 | |
|   xhr.onload = function (e) {
 | |
|     if (e.target.status < 400) {
 | |
|       var location = e.target.getResponseHeader('Location')
 | |
|       this.url = location
 | |
|       this.sendFile_()
 | |
|     } else {
 | |
|       this.onUploadError_(e)
 | |
|     }
 | |
|   }.bind(this)
 | |
|   xhr.onerror = this.onUploadError_.bind(this)
 | |
|   xhr.send(JSON.stringify(this.metadata))
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Send the actual file content.
 | |
|  *
 | |
|  * @private
 | |
|  */
 | |
| MediaUploader.prototype.sendFile_ = function () {
 | |
|   var content = this.file
 | |
|   var end = this.file.size
 | |
| 
 | |
|   if (this.offset || this.chunkSize) {
 | |
|     // Only bother to slice the file if we're either resuming or uploading in chunks
 | |
|     if (this.chunkSize) {
 | |
|       end = Math.min(this.offset + this.chunkSize, this.file.size)
 | |
|     }
 | |
|     content = content.slice(this.offset, end)
 | |
|   }
 | |
| 
 | |
|   var xhr = new XMLHttpRequest()
 | |
|   xhr.open('PUT', this.url, true)
 | |
|   xhr.setRequestHeader('Content-Type', this.contentType)
 | |
|   xhr.setRequestHeader('Content-Range', 'bytes ' + this.offset + '-' + (end - 1) + '/' + this.file.size)
 | |
|   xhr.setRequestHeader('X-Upload-Content-Type', this.file.type)
 | |
|   if (xhr.upload) {
 | |
|     xhr.upload.addEventListener('progress', this.onProgress)
 | |
|   }
 | |
|   xhr.onload = this.onContentUploadSuccess_.bind(this)
 | |
|   xhr.onerror = this.onContentUploadError_.bind(this)
 | |
|   xhr.send(content)
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Query for the state of the file for resumption.
 | |
|  *
 | |
|  * @private
 | |
|  */
 | |
| MediaUploader.prototype.resume_ = function () {
 | |
|   var xhr = new XMLHttpRequest()
 | |
|   xhr.open('PUT', this.url, true)
 | |
|   xhr.setRequestHeader('Content-Range', 'bytes */' + this.file.size)
 | |
|   xhr.setRequestHeader('X-Upload-Content-Type', this.file.type)
 | |
|   if (xhr.upload) {
 | |
|     xhr.upload.addEventListener('progress', this.onProgress)
 | |
|   }
 | |
|   xhr.onload = this.onContentUploadSuccess_.bind(this)
 | |
|   xhr.onerror = this.onContentUploadError_.bind(this)
 | |
|   xhr.send()
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Extract the last saved range if available in the request.
 | |
|  *
 | |
|  * @param {XMLHttpRequest} xhr Request object
 | |
|  */
 | |
| MediaUploader.prototype.extractRange_ = function (xhr) {
 | |
|   var range = xhr.getResponseHeader('Range')
 | |
|   if (range) {
 | |
|     this.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Handle successful responses for uploads. Depending on the context,
 | |
|  * may continue with uploading the next chunk of the file or, if complete,
 | |
|  * invokes the caller's callback.
 | |
|  *
 | |
|  * @private
 | |
|  * @param {object} e XHR event
 | |
|  */
 | |
| MediaUploader.prototype.onContentUploadSuccess_ = function (e) {
 | |
|   if (e.target.status === 200 || e.target.status === 201) {
 | |
|     this.onComplete(e.target.response)
 | |
|   } else if (e.target.status === 308) {
 | |
|     this.extractRange_(e.target)
 | |
|     this.retryHandler.reset()
 | |
|     this.sendFile_()
 | |
|   } else {
 | |
|     this.onContentUploadError_(e)
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Handles errors for uploads. Either retries or aborts depending
 | |
|  * on the error.
 | |
|  *
 | |
|  * @private
 | |
|  * @param {object} e XHR event
 | |
|  */
 | |
| MediaUploader.prototype.onContentUploadError_ = function (e) {
 | |
|   if (e.target.status && e.target.status < 500) {
 | |
|     this.onError(e.target.response)
 | |
|   } else {
 | |
|     this.retryHandler.retry(this.resume_.bind(this))
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Handles errors for the initial request.
 | |
|  *
 | |
|  * @private
 | |
|  * @param {object} e XHR event
 | |
|  */
 | |
| MediaUploader.prototype.onUploadError_ = function (e) {
 | |
|   this.onError(e.target.response) // TODO - Retries for initial upload
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Construct a query string from a hash/object
 | |
|  *
 | |
|  * @private
 | |
|  * @param {object} [params] Key/value pairs for query string
 | |
|  * @return {string} query string
 | |
|  */
 | |
| MediaUploader.prototype.buildQuery_ = function (params) {
 | |
|   params = params || {}
 | |
|   return Object.keys(params).map(function (key) {
 | |
|     return encodeURIComponent(key) + '=' + encodeURIComponent(params[key])
 | |
|   }).join('&')
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Build the drive upload URL
 | |
|  *
 | |
|  * @private
 | |
|  * @param {string} [id] File ID if replacing
 | |
|  * @param {object} [params] Query parameters
 | |
|  * @return {string} URL
 | |
|  */
 | |
| MediaUploader.prototype.buildUrl_ = function (id, params, baseUrl) {
 | |
|   var url = baseUrl || 'https://www.googleapis.com/upload/drive/v2/files/'
 | |
|   if (id) {
 | |
|     url += id
 | |
|   }
 | |
|   var query = this.buildQuery_(params)
 | |
|   if (query) {
 | |
|     url += '?' + query
 | |
|   }
 | |
|   return url
 | |
| }
 | |
| 
 | |
| window.MediaUploader = MediaUploader
 |