/**
* Provides core Upload-functionality.
* Also defines` HTMLInputElement.prototype.sendFiles`.
*
* The Uploader is a Class, which can be instantiated with a `config`-object:
* {url: {String}, the default url to send to
* params: {Object}, the default params that will be send with the request
* options: {Object}, the xhr-options (see http://itsa.io/api/classes/IO.html#method_sendBlob)
* maxFileSize: {Number}, the maximum filesize for every single file to be accepted. Note that the server needs to
* controll this, however, when set, the uploader is enabled to show a proper warning while
* rejecting the io-promise
* totalFileSize: {Number} the maximum filesize for all files (cummulated) to be accepted. Note that the server needs to
* controll this, however, when set, the uploader is enabled to show a proper warning while
* rejecting the io-promise
* }
*
* <i>Copyright (c) 2014 ITSA - https://github.com/itsa</i>
* New BSD License - http://choosealicense.com/licenses/bsd-3-clause/
*
* @module uploader
* @class Uploader
*/
"use strict";
require('polyfill/polyfill-base.js');
require('./css/uploader.css');
var NAME = '[uploader]: ',
later = require('utils').later,
jsext = require('js-ext/js-ext.js'), // we need the full version
Classes = jsext.Classes,
createHashMap = jsext.createHashMap,
TEMPLATE = '<input class="uploader-hidden-input" type="file">';
module.exports = function (window) {
var Event = require('itsa-event'),
IO = require("itsa-io/extra/io-filetransfer.js")(window),
Uploader;
// to prevent multiple Uploader instances
// (which might happen: http://nodejs.org/docs/latest/api/modules.html#modules_module_caching_caveats)
// we make sure Uploader is defined only once. Therefore we bind it to `window` and return it if created before
// We need a singleton Uploader, because submodules might merge in. You can't have them merging
// into some other Uploader-instance than which is used.
window._ITSAmodules || Object.protectedProp(window, '_ITSAmodules', createHashMap());
/*jshint boss:true */
if (Uploader=window._ITSAmodules.Uploader) {
/*jshint boss:false */
return Uploader; // Uploader was already created
}
Uploader = Classes.createClass(function(config) {
/**
* Hidden `input-node` of the type `file` that is being used to transfer the files.
*
* @property _inputNode
* @type DOMNode
* @private
* @since 0.0.1
*/
/**
* Private Array with objects of this structure: {name: xxx, size: xxx}.
* Holds the file-info of all files that have been send by the last transfer.
* Can be used by the method: getLastSent()
*
* @property _lastfiles
* @type Array
* @default []
* @private
* @since 0.0.1
*/
var instance = this,
inputNode;
config || (config={});
instance._inputNode = inputNode = window.document.body.addSystemElement(TEMPLATE);
instance.defaultParams = {};
instance.defaultOptions = {};
instance.setDefaults(config.url, config.params, config.options, config.maxFileSize, config.totalFileSize);
instance._lastfiles = [];
instance.defineEvent('send').defaultFn(instance._defFnSend);
instance.defineEvent('selectfiles').defaultFn(instance._defFnSelectFiles);
instance.defineEvent('fileschanged').defaultFn(function(e) {
var self = e.target;
self._inputNode.getData('autoSend') && self.send(instance._selectedPayload);
});
instance.after('change', function() {
/**
* Fired internally whenever the selected files are changed.
* Its defaultFn will start sending the files (if the selection is triggered with `autoSend=true`)
*
* @event uploader:fileschanged
* @since 0.1
*/
instance.emit('fileschanged', instance._selectedPayload);
}, function(e) {
return (e.target===inputNode);
});
},
{
/**
* Clears the default transition-values that are being used for the values of `url`, params` and `options`.
*
* @method clearDefaults
* @chainable
* @since 0.0.1
*/
clearDefaults: function() {
var instance = this;
instance.defaultURL = null;
instance.defaultParams = {};
instance.defaultOptions = {};
instance.defaultMaxFileSize = null;
instance.defaultTotalFileSize = null;
return instance;
},
/**
* Returns the number of files that are currently selected.
*
* @method count
* @return {number} Number of files currently selected
* @since 0.0.1
*/
count: function() {
return this._inputNode.files.length;
},
/**
* Destroys the fileselector: removes all eventlisteners and removes its protected `input`-domNode.
*
* @method destroy
* @since 0.0.1
*/
destroy: function() {
this._inputNode.remove();
},
/**
* Returns the protected `input`-domNode whichis being used by this uploader-instance.
*
* @method getDomNode
* @return {DOMNode} protected `input`-domNode
* @since 0.0.1
*/
getDomNode: function() {
return this._inputNode;
},
/**
* Returns the currently selected files. This is an `Array-like` object, not a true Array.
*
* @method getFiles
* @return {Array-like} protected `input`-domNode
* @since 0.0.1
*/
getFiles: function() {
return this._inputNode.files;
},
/**
* Returns the size of the largest file that is currently selected.
*
* @method getLargestFileSize
* @return {Number} The size of the largest file in bytes
* @since 0.0.1
*/
getLargestFileSize: function() {
var instance = this,
files = instance._inputNode.files,
len = files.length,
largest = 0,
i, file;
for (i=0; i<len; i++) {
file = files[i];
(file.size>largest) && (largest=file.size);
}
return largest;
},
/**
* Returns the last send-files.
* This is handy to know, because after transmission, getFiles() will return empty.
* This is an true Array with objects of this structure: {name: xxx, size: xxx}
*
* @method getLastSent
* @return {Array} The last sent files
* @since 0.0.1
*/
getLastSent: function() {
return this._lastfiles;
},
/**
* Returns the total size of all files that are currently selected.
*
* @method getTotalFileSize
* @return {Number} The size of all files in bytes
* @since 0.0.1
*/
getTotalFileSize: function() {
var instance = this,
files = instance._inputNode.files,
len = files.length,
total = 0,
i, file;
for (i=0; i<len; i++) {
file = files[i];
total += file.size;
}
return total;
},
/**
* Whether there are currently files selected.
*
* @method hasFiles
* @return {number} Number of selected files
* @since 0.0.1
*/
hasFiles: function() {
return (this.count()>0);
},
/**
* Pops-up the browser's fileselect, by emitting the 'uploader:selectfiles'-event.
* The fileselector allows you to send only 1 file at a time.
* If `payload.autoSend` is set, the files will automaticly be send after selection.
* You also can set other properties at the payload --> these will be available at the listeners.
*
* @method selectFile
* @params [payload] {Object}
* @params [payload.autoSend] {Boolean}
* @chainable
* @since 0.0.1
*/
selectFile: function(payload) {
var instance = this;
instance._selectedPayload = payload ? payload.deepClone() : {};
/**
* Fired to start selecting the files.
* Its defaultFn will pop-up the file-selector.
*
* @event uploader:selectfiles
* @since 0.1
*/
instance.emit('selectfiles', instance._selectedPayload);
return instance;
},
/**
* Pops-up the browser's fileselect, by emitting the 'uploader:selectfiles'-event.
* The fileselector allows you to send multiple files at a time.
* If `payload.autoSend` is set, the files will automaticly be send after selection.
* You also can set other properties at the payload --> these will be available at the listeners.
*
* @method selectFiles
* @params [payload] {Object}
* @params [payload.autoSend] {Boolean}
* @chainable
* @since 0.0.1
*/
selectFiles: function(payload) {
var instance = this;
instance._selectedPayload = payload ? payload.deepClone() : {};
instance._selectedPayload.multiple = true;
instance.emit('selectfiles', instance._selectedPayload);
return instance;
},
/**
* Send the selected files, by emitting the 'uploader:send'-event.
* If `payload.url`, `payload.url` or `payload.url` is set, then these will overrule the default
* values (the way they were set at initiation, or by using `setDefaults`).
* You also can set other properties at the payload --> these will be available at the listeners.
*
* @method send
* @params [payload] {Object}
* @params [payload.url] {String}
* @params [payload.params] {Object}
* @params [payload.options] {Object}
* @chainable
* @since 0.0.1
*/
send: function(payload) {
var instance = this;
/**
* Fired to start uploading through io.
* Its defaultFn will invoke `sendFiles` of the input-node.
*
* @event uploader:send
* @since 0.1
*/
instance.emit('send', payload && payload.deepClone());
return instance;
},
/**
* Sets the default transition-values that are being used for the values of `url`, params` and `options`.
* These values might have been set during initialization, if so, any values passed will be overrule the previous.
*
* @method setDefaults
* @params [url] {String} the default url to send to
* @params [params] {Object} the default params that will be send with the request
* @params [options] {Object} the xhr-options (see http://itsa.io/api/classes/IO.html#method_sendBlob)
* @params [maxFileSize] {Number} the maximum filesize for every single file to be accepted. Note that the server needs to
* controll this, however, when set, the uploader is enabled to show a proper warning while
* rejecting the io-promise
* @params [totalFileSize] {Number} the maximum filesize for all files (cummulated) to be accepted. Note that the server needs to
* controll this, however, when set, the uploader is enabled to show a proper warning while
* rejecting the io-promise
* @chainable
* @since 0.0.1
*/
setDefaults: function(url, params, options, maxFileSize, totalFileSize) {
var instance = this;
(typeof url==='string') && (instance.defaultURL=url);
params && (instance.defaultParams=params.deepClone());
options && (instance.defaultOptions=options.deepClone());
maxFileSize && (instance.defaultMaxFileSize=maxFileSize);
totalFileSize && (instance.defaultTotalFileSize=totalFileSize);
return instance;
},
/**
* Default function for the `uploader:selectfiles`-event
*
* @method _defFnSelectFiles
* @param e {Object} eventobject
* @param [e.multiple] {Boolean} whether to support multiple selected files
* @private
* @since 0.0.1
*/
_defFnSelectFiles: function(e) {
var instance = e.target,
inputNode = instance._inputNode,
autoSend = e.autoSend,
eMultiple = e.multiple,
multiple = (typeof eMultiple === 'boolean') ? eMultiple : false;
inputNode.toggleAttr('multiple', 'true', multiple);
inputNode.setData('autoSend', autoSend);
inputNode.showFileSelect();
},
/**
* Default function for the `uploader:send`-event
*
* @method _defFnSend
* @param e {Object} eventobject
* @param [e.url] {String} overrules the default `url`
* @param [e.params] {Object} overrules the default `params`
* @param [e.options] {Object} overrules the default `options`
* @private
* @since 0.0.1
*/
_defFnSend: function(e) {
var instance = this,
inputNode = instance._inputNode,
url = e.url,
params = e.params,
options = e.options,
defaultTotalFileSize = instance.defaultTotalFileSize,
defaultMaxFileSize = instance.defaultMaxFileSize,
sendOptions, originalProgressFn, largest, total;
if (!inputNode || inputNode.files.length===0) {
return Promise.reject('no file selected');
}
/*jshint boss:true */
if (defaultMaxFileSize && ((largest=instance.getLargestFileSize())>defaultMaxFileSize)) {
return Promise.reject('one of the files exceeds the maximum filesize of '+largest+' bytes');
}
if (defaultTotalFileSize && ((total=instance.getTotalFileSize())>defaultTotalFileSize)) {
return Promise.reject('the size of all files exceeds the maximum of '+total+' bytes');
}
/*jshint boss:false */
sendOptions = options ? options.deepClone() : instance.defaultOptions.deepClone();
sendOptions.emptyFiles = true;
// redefine the argument of the progress-callback:
if (sendOptions.progressfn) {
originalProgressFn = sendOptions.progressfn;
sendOptions.progressfn = function(data) {
e.ioPromise = data.target;
e.total = data.total;
e.loaded = data.loaded;
originalProgressFn(e);
};
}
instance._storeLastSent();
return inputNode.sendFiles(url || instance.defaultURL, params || instance.defaultParams, sendOptions);
},
/**
* Stores the files that are sent into an internal hash, which can be read by `getLastSent()`.
*
* @method _storeLastSent
* @private
* @chainable
* @since 0.0.1
*/
_storeLastSent: function() {
var instance = this,
lastFiles = instance._lastfiles,
files = instance._inputNode.files,
len = files.length,
i, file;
lastFiles.length = 0;
for (i=0; i<len; i++) {
file = files[i];
lastFiles.push({
name: file.name,
size: file.size
});
}
return instance;
}
});
Uploader.mergePrototypes(Event.Listener);
Uploader.mergePrototypes(Event.Emitter('uploader'));
/**
* Sends the input's files by using an AJAX PUT request.
* Additional parameters can be through the `params` argument.
*
* The Promise gets fulfilled if the server responses with `STATUS-CODE` in the 200-range (excluded 204).
* It will be rejected if a timeout occurs (see `options.timeout`), or if `xhr.abort()` gets invoked.
*
* Note: `params` should be a plain object with only primitive types which are transformed into key/value pairs.
*
* @for HTMLInputElement
* @method sendFiles
* @param url {String} URL of the resource server
* @param [params] {Object} additional parameters. NOTE: these will be set as HEADERS like `x-data-parameter` on the request!
* should be a plain object with only primitive types which are transformed into key/value pairs.
* @param [options] {Object}
* @param [options.sync=false] {boolean} By default, all requests are sent asynchronously. To send synchronous requests, set to true.
* @param [options.headers] {Object} HTTP request headers.
* @param [options.timeout=300000] {Number} to timeout the request, leading into a rejected Promise. Defaults to 5 minutes
* @param [options.progressfn] {Function} callbackfunction in case you want to process upload-status.
* Function has 3 parameters: total, loaded and target (io-promise)
* @param [options.withCredentials=false] {boolean} Whether or not to send credentials on the request.
* @param [options.parseJSONDate=false] {boolean} Whether the server returns JSON-stringified data which has Date-objects.
* @param [options.emptyFiles=true] {boolean} Whether the empty the inputElement after transmitting has completed
* @return {Promise}
* on success:
* Object any received data
* on failure an Error object
* reason {Error}
* The returned promise has an `abort`-method to cancel all transfers.
*/
window.HTMLInputElement.prototype.sendFiles = function(url, params, options) {
var instance = this,
files = instance.files,
len = files.length,
hash = [],
promisesById = {},
promise, ioPromise, file, i, totalsize, originalProgressFn;
options || (options={});
(typeof options.emptyFiles==='boolean') || (options.emptyFiles=true);
if (len===1) {
file = files[0];
promise = IO.sendBlob(url, file, params, options);
}
else if (len>1) {
if (options.progressfn) {
totalsize = 0;
originalProgressFn = options.progressfn;
options.progressfn = function(data) {
var promiseInstance = data.target,
totalLoaded = 0;
promisesById[promiseInstance._id] = data.loaded;
promisesById.each(function(value) {
totalLoaded += value;
});
originalProgressFn({
total: totalsize,
loaded: totalLoaded,
target: promise
});
};
}
// files is array-like, no true array
for (i=0; i<len; i++) {
file = files[i];
ioPromise = IO.sendBlob(url, file, params, options);
// we are interested in the total size of ALL files
if (options.progressfn) {
totalsize += file.size;
ioPromise._id = i;
}
hash.push(ioPromise);
}
promise = window.Promise.finishAll(hash).then(function(response) {
var rejected = response.rejected;
rejected.forEach(function(ioError) {
if (ioError) {
throw new Error(ioError);
}
});
});
promise.abort = function() {
hash.forEach(function(ioPromise) {
ioPromise.abort();
});
};
}
else {
promise = Promise.reject('No files selected');
}
if (options.emptyFiles && (len>0)) {
// empty ON THE NEXT stack (not microstack), to ensure all previous methods are processing
later(function () {
instance.resetFileSelect();
}, 0);
}
return promise;
};
window._ITSAmodules.Uploader = Uploader;
return Uploader;
};