/* * MIT Licensed * http://www.23developer.com/opensource * http://github.com/23/resumable.js * Steffen Tiedemann Christensen, steffen@23company.com */ (function(){ "use strict"; var Resumable = function(opts){ if ( !(this instanceof Resumable) ) { return new Resumable(opts); } this.version = 1.0; // SUPPORTED BY BROWSER? // Check if these features are support by the browser: // - File object type // - Blob object type // - FileList object type // - slicing files this.support = ( (typeof(File)!=='undefined') && (typeof(Blob)!=='undefined') && (typeof(FileList)!=='undefined') && (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) ); if(!this.support) return(false); // PROPERTIES var $ = this; $.files = []; $.defaults = { chunkSize:1*1024*1024, forceChunkSize:false, simultaneousUploads:3, fileParameterName:'file', chunkNumberParameterName: 'resumableChunkNumber', chunkSizeParameterName: 'resumableChunkSize', currentChunkSizeParameterName: 'resumableCurrentChunkSize', totalSizeParameterName: 'resumableTotalSize', typeParameterName: 'resumableType', identifierParameterName: 'resumableIdentifier', fileNameParameterName: 'resumableFilename', relativePathParameterName: 'resumableRelativePath', totalChunksParameterName: 'resumableTotalChunks', throttleProgressCallbacks: 0.5, query:{}, headers:{}, preprocess:null, method:'multipart', uploadMethod: 'POST', testMethod: 'GET', prioritizeFirstAndLastChunk:false, target:'/', parameterNamespace:'', testChunks:true, generateUniqueIdentifier:null, getTarget:null, maxChunkRetries:undefined, chunkRetryInterval:undefined, permanentErrors:[400, 404, 415, 500, 501], maxFiles:undefined, withCredentials:false, xhrTimeout:0, clearInput:true, maxFilesErrorCallback:function (files, errorCount) { var maxFiles = $.getOpt('maxFiles'); alert('Please upload no more than ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); }, minFileSize:1, minFileSizeErrorCallback:function(file, errorCount) { alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); }, maxFileSize:undefined, maxFileSizeErrorCallback:function(file, errorCount) { alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); }, fileType: [], fileTypeErrorCallback: function(file, errorCount) { alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); } }; $.opts = opts||{}; $.getOpt = function(o) { var $opt = this; // Get multiple option if passed an array if(o instanceof Array) { var options = {}; $h.each(o, function(option){ options[option] = $opt.getOpt(option); }); return options; } // Otherwise, just return a simple option if ($opt instanceof ResumableChunk) { if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } else { $opt = $opt.fileObj; } } if ($opt instanceof ResumableFile) { if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } else { $opt = $opt.resumableObj; } } if ($opt instanceof Resumable) { if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } else { return $opt.defaults[o]; } } }; // EVENTS // catchAll(event, ...) // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), // complete(), progress(), error(message, file), pause() $.events = []; $.on = function(event,callback){ $.events.push(event.toLowerCase(), callback); }; $.fire = function(){ // `arguments` is an object, not array, in FF, so: var args = []; for (var i=0; i 0) { for (i = 0; i < entries.length; i++) { entry = entries[i]; if (entry.isFile) { queueLength++; entry.file(function(file) { file.relativePath = '/' + path + '/' + file.name; return enqueueFileAddition(file, event); }, function() { // Error queueLength--; }); } else if (entry.isDirectory) { processDirectory(entry, path + '/' + entry.name, event); } } readEntries(); } return null; }); }; })(this); return readEntries(); }; var queueFiles = []; /** * @summary Add a file to the queue of processed files, if it brings the total up to the expected total, flush the queue * @param file {Object} - File object to be passed along to appendFilesFromFileList eventually * @param [path] {String} - the file's relative path from the originally dropped folder if we are parsing folder content (Chrome only for now) */ var enqueueFileAddition = function(file, event){ if (!file.relativePath) file.fullPath; queueFiles.push(file); // If all the files we expect have shown up, then flush the queue. if (queueFiles.length == queueLength) { appendFilesFromFileList(queueFiles, event); } }; var appendFilesFromFileList = function(fileList, event){ // check for uploading too many files var errorCount = 0; var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { $.removeFile($.files[0]); } else { o.maxFilesErrorCallback(fileList, errorCount++); return false; } } var files = []; $h.each(fileList, function(file){ var fileName = file.name; if(o.fileType.length > 0){ var fileTypeFound = false; for(var index in o.fileType){ var extension = '.' + o.fileType[index]; if(fileName.indexOf(extension, fileName.length - extension.length) !== -1){ fileTypeFound = true; break; } } if (!fileTypeFound) { o.fileTypeErrorCallback(file, errorCount++); return false; } } if (typeof(o.minFileSize)!=='undefined' && file.sizeo.maxFileSize) { o.maxFileSizeErrorCallback(file, errorCount++); return false; } function addFile(uniqueIdentifier){ var pathIndex = $.files.length ? $.files.length : 0; if (!$.getFromUniqueIdentifier(uniqueIdentifier)) {(function(){ file.uniqueIdentifier = uniqueIdentifier; var f = new ResumableFile($, file, uniqueIdentifier); $.files.push(f); files.push(f); f.container = (typeof event != 'undefined' ? event.srcElement : null); window.setTimeout(function(){ $.fire('fileAdded', f, event) },0); })()}; } // directories have size == 0 var uniqueIdentifier = $h.generateUniqueIdentifier(file) if(uniqueIdentifier && typeof uniqueIdentifier.done === 'function' && typeof uniqueIdentifier.fail === 'function'){ uniqueIdentifier .done(function(uniqueIdentifier){ addFile(uniqueIdentifier); }) .fail(function(){ addFile(); }); }else{ addFile(uniqueIdentifier); } }); window.setTimeout(function(){ $.fire('filesAdded', files) },0); }; // INTERNAL OBJECT TYPES function ResumableFile(resumableObj, file, uniqueIdentifier){ var $ = this; $.opts = {}; $.getOpt = resumableObj.getOpt; $._prevProgress = 0; $.resumableObj = resumableObj; $.file = file; $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox $.size = file.size; $.relativePath = file.relativePath || file.webkitRelativePath || $.fileName; $.uniqueIdentifier = uniqueIdentifier; $._pause = false; $.container = ''; var _error = uniqueIdentifier !== undefined; // Callback when something happens within the chunk var chunkEvent = function(event, message){ // event can be 'progress', 'success', 'error' or 'retry' switch(event){ case 'progress': $.resumableObj.fire('fileProgress', $); break; case 'error': $.abort(); _error = true; $.chunks = []; $.resumableObj.fire('fileError', $, message); break; case 'success': if(_error) return; $.resumableObj.fire('fileProgress', $); // it's at least progress if($.isComplete()) { $.resumableObj.fire('fileSuccess', $, message); } break; case 'retry': $.resumableObj.fire('fileRetry', $); break; } }; // Main code to set up a file object with chunks, // packaged to be able to handle retries if needed. $.chunks = []; $.abort = function(){ // Stop current uploads var abortCount = 0; $h.each($.chunks, function(c){ if(c.status()=='uploading') { c.abort(); abortCount++; } }); if(abortCount>0) $.resumableObj.fire('fileProgress', $); }; $.cancel = function(){ // Reset this file to be void var _chunks = $.chunks; $.chunks = []; // Stop current uploads $h.each(_chunks, function(c){ if(c.status()=='uploading') { c.abort(); $.resumableObj.uploadNextChunk(); } }); $.resumableObj.removeFile($); $.resumableObj.fire('fileProgress', $); }; $.retry = function(){ $.bootstrap(); var firedRetry = false; $.resumableObj.on('chunkingComplete', function(){ if(!firedRetry) $.resumableObj.upload(); firedRetry = true; }); }; $.bootstrap = function(){ $.abort(); _error = false; // Rebuild stack of chunks from file $.chunks = []; $._prevProgress = 0; var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); for (var offset=0; offset0.99999 ? 1 : ret)); ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused $._prevProgress = ret; return(ret); }; $.isUploading = function(){ var uploading = false; $h.each($.chunks, function(chunk){ if(chunk.status()=='uploading') { uploading = true; return(false); } }); return(uploading); }; $.isComplete = function(){ var outstanding = false; $h.each($.chunks, function(chunk){ var status = chunk.status(); if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { outstanding = true; return(false); } }); return(!outstanding); }; $.pause = function(pause){ if(typeof(pause)==='undefined'){ $._pause = ($._pause ? false : true); }else{ $._pause = pause; } }; $.isPaused = function() { return $._pause; }; // Bootstrap and return $.resumableObj.fire('chunkingStart', $); $.bootstrap(); return(this); } function ResumableChunk(resumableObj, fileObj, offset, callback){ var $ = this; $.opts = {}; $.getOpt = resumableObj.getOpt; $.resumableObj = resumableObj; $.fileObj = fileObj; $.fileObjSize = fileObj.size; $.fileObjType = fileObj.file.type; $.offset = offset; $.callback = callback; $.lastProgressCallback = (new Date); $.tested = false; $.retries = 0; $.pendingRetry = false; $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished // Computed properties var chunkSize = $.getOpt('chunkSize'); $.loaded = 0; $.startByte = $.offset*chunkSize; $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { // The last chunk will be bigger than the chunk size, but less than 2*chunkSize $.endByte = $.fileObjSize; } $.xhr = null; // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session $.test = function(){ // Set up request and listen for event $.xhr = new XMLHttpRequest(); var testHandler = function(e){ $.tested = true; var status = $.status(); if(status=='success') { $.callback(status, $.message()); $.resumableObj.uploadNextChunk(); } else { $.send(); } }; $.xhr.addEventListener('load', testHandler, false); $.xhr.addEventListener('error', testHandler, false); $.xhr.addEventListener('timeout', testHandler, false); // Add data from the query options var params = []; var parameterNamespace = $.getOpt('parameterNamespace'); var customQuery = $.getOpt('query'); if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); $h.each(customQuery, function(k,v){ params.push([encodeURIComponent(parameterNamespace+k), encodeURIComponent(v)].join('=')); }); // Add extra data to identify chunk params.push([parameterNamespace + $.getOpt('chunkNumberParameterName'), encodeURIComponent($.offset + 1)].join('=')); params.push([parameterNamespace + $.getOpt('chunkSizeParameterName'), encodeURIComponent($.getOpt('chunkSize'))].join('=')); params.push([parameterNamespace + $.getOpt('currentChunkSizeParameterName'), encodeURIComponent($.endByte - $.startByte)].join('=')); params.push([parameterNamespace + $.getOpt('totalSizeParameterName'), encodeURIComponent($.fileObjSize)].join('=')); params.push([parameterNamespace + $.getOpt('typeParameterName'), encodeURIComponent($.fileObjType)].join('=')); params.push([parameterNamespace + $.getOpt('identifierParameterName'), encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); params.push([parameterNamespace + $.getOpt('fileNameParameterName'), encodeURIComponent($.fileObj.fileName)].join('=')); params.push([parameterNamespace + $.getOpt('relativePathParameterName'), encodeURIComponent($.fileObj.relativePath)].join('=')); params.push([parameterNamespace + $.getOpt('totalChunksParameterName'), encodeURIComponent($.fileObj.chunks.length)].join('=')); // Append the relevant chunk and send it $.xhr.open($.getOpt('testMethod'), $h.getTarget(params)); $.xhr.timeout = $.getOpt('xhrTimeout'); $.xhr.withCredentials = $.getOpt('withCredentials'); // Add data from header options var customHeaders = $.getOpt('headers'); if(typeof customHeaders === 'function') { customHeaders = customHeaders($.fileObj, $); } $h.each(customHeaders, function(k,v) { $.xhr.setRequestHeader(k, v); }); $.xhr.send(null); }; $.preprocessFinished = function(){ $.preprocessState = 2; $.send(); }; // send() uploads the actual data in a POST call $.send = function(){ var preprocess = $.getOpt('preprocess'); if(typeof preprocess === 'function') { switch($.preprocessState) { case 0: $.preprocessState = 1; preprocess($); return; case 1: return; case 2: break; } } if($.getOpt('testChunks') && !$.tested) { $.test(); return; } // Set up request and listen for event $.xhr = new XMLHttpRequest(); // Progress $.xhr.upload.addEventListener('progress', function(e){ if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { $.callback('progress'); $.lastProgressCallback = (new Date); } $.loaded=e.loaded||0; }, false); $.loaded = 0; $.pendingRetry = false; $.callback('progress'); // Done (either done, failed or retry) var doneHandler = function(e){ var status = $.status(); if(status=='success'||status=='error') { $.callback(status, $.message()); $.resumableObj.uploadNextChunk(); } else { $.callback('retry', $.message()); $.abort(); $.retries++; var retryInterval = $.getOpt('chunkRetryInterval'); if(retryInterval !== undefined) { $.pendingRetry = true; setTimeout($.send, retryInterval); } else { $.send(); } } }; $.xhr.addEventListener('load', doneHandler, false); $.xhr.addEventListener('error', doneHandler, false); $.xhr.addEventListener('timeout', doneHandler, false); // Set up the basic query data from Resumable var query = {}; query[$.getOpt('chunkNumberParameterName')] = $.offset + 1; query[$.getOpt('chunkSizeParameterName')] = $.getOpt('chunkSize'); query[$.getOpt('currentChunkSizeParameterName')] = $.endByte - $.startByte; query[$.getOpt('totalSizeParameterName')] = $.fileObjSize; query[$.getOpt('typeParameterName')] = $.fileObjType; query[$.getOpt('identifierParameterName')] = $.fileObj.uniqueIdentifier; query[$.getOpt('fileNameParameterName')] = $.fileObj.fileName; query[$.getOpt('relativePathParameterName')] = $.fileObj.relativePath; query[$.getOpt('totalChunksParameterName')] = $.fileObj.chunks.length; // Mix in custom data var customQuery = $.getOpt('query'); if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); $h.each(customQuery, function(k,v){ query[k] = v; }); var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), bytes = $.fileObj.file[func]($.startByte,$.endByte), data = null, target = $.getOpt('target'); var parameterNamespace = $.getOpt('parameterNamespace'); if ($.getOpt('method') === 'octet') { // Add data from the query options data = bytes; var params = []; $h.each(query, function(k,v){ params.push([encodeURIComponent(parameterNamespace+k), encodeURIComponent(v)].join('=')); }); target = $h.getTarget(params); } else { // Add data from the query options data = new FormData(); $h.each(query, function(k,v){ data.append(parameterNamespace+k,v); }); data.append(parameterNamespace+$.getOpt('fileParameterName'), bytes); } var method = $.getOpt('uploadMethod'); $.xhr.open(method, target); if ($.getOpt('method') === 'octet') { $.xhr.setRequestHeader('Content-Type', 'application/octet-stream'); } $.xhr.timeout = $.getOpt('xhrTimeout'); $.xhr.withCredentials = $.getOpt('withCredentials'); // Add data from header options var customHeaders = $.getOpt('headers'); if(typeof customHeaders === 'function') { customHeaders = customHeaders($.fileObj, $); } $h.each(customHeaders, function(k,v) { $.xhr.setRequestHeader(k, v); }); $.xhr.send(data); }; $.abort = function(){ // Abort and reset if($.xhr) $.xhr.abort(); $.xhr = null; }; $.status = function(){ // Returns: 'pending', 'uploading', 'success', 'error' if($.pendingRetry) { // if pending retry then that's effectively the same as actively uploading, // there might just be a slight delay before the retry starts return('uploading'); } else if(!$.xhr) { return('pending'); } else if($.xhr.readyState<4) { // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening return('uploading'); } else { if($.xhr.status == 200 || $.xhr.status == 201) { // HTTP 200, 201 (created) return('success'); } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { // HTTP 415/500/501, permanent error return('error'); } else { // this should never happen, but we'll reset and queue a retry // a likely case for this would be 503 service unavailable $.abort(); return('pending'); } } }; $.message = function(){ return($.xhr ? $.xhr.responseText : ''); }; $.progress = function(relative){ if(typeof(relative)==='undefined') relative = false; var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); if($.pendingRetry) return(0); if(!$.xhr || !$.xhr.status) factor*=.95; var s = $.status(); switch(s){ case 'success': case 'error': return(1*factor); case 'pending': return(0*factor); default: return($.loaded/($.endByte-$.startByte)*factor); } }; return(this); } // QUEUE $.uploadNextChunk = function(){ var found = false; // In some cases (such as videos) it's really handy to upload the first // and last chunk of a file quickly; this let's the server check the file's // metadata and determine if there's even a point in continuing. if ($.getOpt('prioritizeFirstAndLastChunk')) { $h.each($.files, function(file){ if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { file.chunks[0].send(); found = true; return(false); } if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { file.chunks[file.chunks.length-1].send(); found = true; return(false); } }); if(found) return(true); } // Now, simply look for the next, best thing to upload $h.each($.files, function(file){ if(file.isPaused()===false){ $h.each(file.chunks, function(chunk){ if(chunk.status()=='pending' && chunk.preprocessState === 0) { chunk.send(); found = true; return(false); } }); } if(found) return(false); }); if(found) return(true); // The are no more outstanding chunks to upload, check is everything is done var outstanding = false; $h.each($.files, function(file){ if(!file.isComplete()) { outstanding = true; return(false); } }); if(!outstanding) { // All chunks have been uploaded, complete $.fire('complete'); } return(false); }; // PUBLIC METHODS FOR RESUMABLE.JS $.assignBrowse = function(domNodes, isDirectory){ if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; $h.each(domNodes, function(domNode) { var input; if(domNode.tagName==='INPUT' && domNode.type==='file'){ input = domNode; } else { input = document.createElement('input'); input.setAttribute('type', 'file'); input.style.display = 'none'; domNode.addEventListener('click', function(){ input.style.opacity = 0; input.style.display='block'; input.focus(); input.click(); input.style.display='none'; }, false); domNode.appendChild(input); } var maxFiles = $.getOpt('maxFiles'); if (typeof(maxFiles)==='undefined'||maxFiles!=1){ input.setAttribute('multiple', 'multiple'); } else { input.removeAttribute('multiple'); } if(isDirectory){ input.setAttribute('webkitdirectory', 'webkitdirectory'); } else { input.removeAttribute('webkitdirectory'); } // When new files are added, simply append them to the overall list input.addEventListener('change', function(e){ appendFilesFromFileList(e.target.files,e); var clearInput = $.getOpt('clearInput'); if (clearInput) { e.target.value = ''; } }, false); }); }; $.assignDrop = function(domNodes){ if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; $h.each(domNodes, function(domNode) { domNode.addEventListener('dragover', preventDefault, false); domNode.addEventListener('dragenter', preventDefault, false); domNode.addEventListener('drop', onDrop, false); }); }; $.unAssignDrop = function(domNodes) { if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; $h.each(domNodes, function(domNode) { domNode.removeEventListener('dragover', preventDefault); domNode.removeEventListener('dragenter', preventDefault); domNode.removeEventListener('drop', onDrop); }); }; $.isUploading = function(){ var uploading = false; $h.each($.files, function(file){ if (file.isUploading()) { uploading = true; return(false); } }); return(uploading); }; $.upload = function(){ // Make sure we don't start too many uploads at once if($.isUploading()) return; // Kick off the queue $.fire('uploadStart'); for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { $.uploadNextChunk(); } }; $.pause = function(){ // Resume all chunks currently being uploaded $h.each($.files, function(file){ file.abort(); }); $.fire('pause'); }; $.cancel = function(){ $.fire('beforeCancel'); for(var i = $.files.length - 1; i >= 0; i--) { $.files[i].cancel(); } $.fire('cancel'); }; $.progress = function(){ var totalDone = 0; var totalSize = 0; // Resume all chunks currently being uploaded $h.each($.files, function(file){ totalDone += file.progress()*file.size; totalSize += file.size; }); return(totalSize>0 ? totalDone/totalSize : 0); }; $.addFile = function(file, event){ appendFilesFromFileList([file], event); }; $.removeFile = function(file){ for(var i = $.files.length - 1; i >= 0; i--) { if($.files[i] === file) { $.files.splice(i, 1); } } }; $.getFromUniqueIdentifier = function(uniqueIdentifier){ var ret = false; $h.each($.files, function(f){ if(f.uniqueIdentifier==uniqueIdentifier) ret = f; }); return(ret); }; $.getSize = function(){ var totalSize = 0; $h.each($.files, function(file){ totalSize += file.size; }); return(totalSize); }; $.handleDropEvent = function (e) { onDrop(e); }; $.handleChangeEvent = function (e) { appendFilesFromFileList(e.target.files, e); e.target.value = ''; }; return(this); }; // Node.js-style export for Node and Component if (typeof module != 'undefined') { module.exports = Resumable; } else if (typeof define === "function" && define.amd) { // AMD/requirejs: Define the module define(function(){ return Resumable; }); } else { // Browser: Expose to window window.Resumable = Resumable; } })();