1 /**
  2  * @author Gillis Haasnoot <gillis.haasnoot@gmail.com>
  3  * @package Banana.Controls
  4  * @summary ChunkedUpload experimental control.
  5  */
  6 
  7 goog.provide('Banana.Controls.ChunkedUpload');
  8 
  9 /** @namespace Banana.Controls.ChunkUpload*/
 10 namespace('Banana.Controls').ChunkedUpload = Banana.Controls.Panel.extend({
 11 /** @lends Banana.Controls.ChunkUpload.prototype */
 12 
 13 	/**
 14 	 * creates a chunked upload control
 15 	 * currently only supported by limited amount of browsers: chrome and firefox.
 16 	 * uploads file is small chunks. the upload control ensures that chunks are send to the
 17 	 * server in the correct order. Server is responsible to reconstruct the chunks to one file
 18 	 * during file upload the first time a chunk is uploaded we append "firstChunk" to the post params
 19 	 * last time a chunk is uploaded we append "lastChunk" to the post params.
 20 	 * a "uid" param is appended to identify the file serverside
 21 	 * @constructs
 22 	 * @extends Banana.Controls.Panel
 23 	 */
 24 	init : function()
 25 	{
 26 		this._super();
 27 
 28 		if (!this.isSupported())
 29 		{
 30 			this.triggerError = true;
 31 			log.error("chunk upload not supported by the browser");
 32 			return;
 33 		}
 34 
 35 		this.files = [];
 36 		this.chunkSize = 2000000;
 37 		this.maxSimultaneousUpload = 2;
 38 
 39 		var form = new Banana.Controls.Form();
 40 		form.setAttribute('enctype',"multipart/form-data");
 41 		form.setAttribute('method',"post");
 42 
 43 		var maxInput = new Banana.Controls.InputControl();
 44 		maxInput.setAttribute('name',"MAX_FILE_SIZE");
 45 		maxInput.setAttribute('value',"99999999");
 46 		maxInput.setAttribute('type',"hidden");
 47 
 48 		var fileInput = new Banana.Controls.FileInput();
 49 		fileInput.bind('change',this.getProxy(function(){
 50 			this.handleFilesSelected(fileInput.getFiles());
 51 		}));
 52 		fileInput.setId('fileInput');
 53 
 54 		form.addControl(maxInput);
 55 		form.addControl(fileInput);
 56 
 57 		this.addControl(form);
 58 
 59 		this.registerCustomEvents();
 60 	},
 61 
 62 	/**
 63 	 * @ignore
 64 	 */
 65 	createComponents : function()
 66 	{
 67 		if (this.triggerError)
 68 		{
 69 			this.triggerEvent("browserNotSupported");
 70 		}
 71 	},
 72 
 73 	/**
 74 	 * set the size of a chunk. the lower you set the chunk, the lower memory usuage will be, but also
 75 	 *
 76 	 * @param {int} chunkSize
 77 	 * @return {this}
 78 	 */
 79 	setChunkSize : function(chunkSize)
 80 	{
 81 		this.chunkSize = chunkSize;
 82 		return this;
 83 	},
 84 
 85 	/**
 86 	 * max upload at the same time
 87 	 * @param {int} maxSimultaneousUpload
 88 	 * @return {this}
 89 	 */
 90 	setMaxSimultaneousUpload : function(maxSimultaneousUpload)
 91 	{
 92 		this.maxSimultaneousUpload = maxSimultaneousUpload;
 93 		return this;
 94 	},
 95 
 96 	/**
 97 	 *
 98 	 * @param {boolean} bool when true filedialog supports multiple file selection
 99 	 * @return {this}
100 	 */
101 	setMultipleFilesUpload : function(bool)
102 	{
103 		var control = this.findControl('fileInput');
104 		if (!control)
105 		{
106 			return;
107 		}
108 		control.setMultiple(bool);
109 
110 		return this;
111 	},
112 
113 	/**
114 	 * sets the url to post to
115 	 * @param {String} url
116 	 * @return {this}
117 	 */
118 	setPostUrl : function(url)
119 	{
120 		this.uploadFile = url;
121 		return this;
122 	},
123 
124 	/**
125 	 * Check whether our upload control is supported by the browser
126 	 * @return {boolean}
127 	 */
128 	isSupported : function()
129 	{
130 		if (!window.File)
131 		{
132 			return false;
133 		}
134 
135 		//check file slice
136 		if (!File.prototype.webkitSlice && !File.prototype.mozSlice && !File.prototype.slice)
137 		{
138 			return false;
139 		}
140 
141 		//check level 2 xhr
142 	    var xhr = new XMLHttpRequest();
143 	    return !! (xhr && ('upload' in xhr) && ('onprogress' in xhr.upload));
144 	},
145 
146 	/**
147 	 * Handle file selected
148 	 * @param {Array} files
149 	 */
150 	handleFilesSelected : function(files)
151 	{
152 		this.currentUploading = 0;
153 		this.currentUploaded = 0;
154 		this.currentFileCount = files.length;
155 		this.formFiles = files;
156 		this.files = [];
157 		this.startTime = new Date().getTime();
158 
159 		this.triggerEvent("filesSelected",{"files":files});
160 	},
161 
162 	/**
163 	 * Here we listen to the fileupload event.
164 	 * we create a construction here to use maxSimultaneousUpload and
165 	 * ensure completion of all files
166 	 */
167 	registerCustomEvents : function()
168 	{
169 		this.bind('fileUploadedInternal',this.getProxy(function(e,data){
170 
171 			this.currentUploaded++;
172 			this.currentUploading--;
173 
174 			///console.log('finished file '+data.file.name+' uploaded. currentUploaded',this.currentUploaded, 'currentUploading',this.currentUploading);
175 			//all files are uploaded
176 			if (this.currentUploaded == this.currentFileCount)
177 			{
178 				this.triggerEvent('filesUploaded',{files:this.formFiles});
179 				return;
180 			}
181 
182 			//all files are at least busy
183 			if (this.currentUploading+this.currentUploaded == this.currentFileCount)
184 			{
185 				return;
186 			}
187 
188 			//all files beeing uploaded are uploaded. do we have more files to upload?
189 			if (this.currentUploading < this.maxSimultaneousUpload)
190 			{
191 				this.uploadFiles(this.formFiles,this.currentUploaded+this.currentUploading,this.currentUploaded+this.currentUploading+this.maxSimultaneousUpload-this.currentUploading);
192 			}
193 		}));
194 	},
195 
196 	/**
197 	 * Executes the upload procedure
198 	 * @param {Array} of files
199 	 * @param {int} from
200 	 * @param {to} to
201 	 */
202 	uploadFiles : function(files,from,to)
203 	{
204 		//from and to are used to maximize simultaneous uploads
205 		if (!from)
206 		{
207 			from = 0;
208 		}
209 		if (!to)
210 		{
211 			to	= this.maxSimultaneousUpload > files.length ? files.length : this.maxSimultaneousUpload;
212 		}
213 
214 		this.currentUploading+=to-from;
215 
216 		var i,len;
217 		for (i=from,len=to;i < len; i++)
218 		{
219 			var file = files[i];
220 			file.loaded = 0;
221 			file.uid = Banana.Util.generateUniqueId();
222 			file.completion = 0;
223 
224 			this.files[file.name] = file;
225 
226 			this.processFileChunkFrom(file,0,null);
227 		}
228 	},
229 
230 	/**
231 	 * Processes a chunk from a file
232 	 * this method is recalled everytime a chunk is completed.
233 	 * it stops when all chunks are completed
234 	 * @param {Object} file
235 	 * @param {int} index part of the file chunk
236 	 * @param {function} cb called after each upload chunk completion
237 	 */
238 	processFileChunkFrom : function(file,index,cb)
239 	{
240 		var start = file.loaded;
241 		var end = file.loaded+this.chunkSize;
242 
243 		//if we have an error in the file. we trigger event and stop processing
244 		if (file.uploadError)
245 		{
246 			this.triggerEvent("fileUploadError",{"file":file});
247 			return;
248 		}
249 
250 		//we always end up here when all chunks are uploaded
251 		if (start >= file.size)
252 		{
253 			//trigger this event to make sure we always received a fileUploading event prior to fileUploaded
254 			//specialy with small files the progress event is sometimes not fired by the browser
255 			this.triggerEvent("fileUploading",{"file":file});
256 
257 			this.triggerEvent("fileUploaded",{"file":file});
258 			this.triggerEvent("fileUploadedInternal",{"file":file});
259 			return;
260 		}
261 
262 		//we end up here at the last chunk
263 		if (end >= file.size)
264 		{
265 			end = file.size;
266 		}
267 
268 		//slice method depending on the browser
269 		var slice = file.webkitSlice || file.mozSlice || file.slice
270 
271 		//get a chunk from the file
272 		var chunk = slice.call(file,start,end);
273 
274 		chunk.filename = file.name;
275 		chunk.index = index;
276 
277 		//below we have a callback function encapsulated in a directly executed function
278 		//we do this to ensure a closure of where the file resists in.
279 		//the situation can occur that we upload 2 different files at the same time. we want the
280 		//callback function access the right file always. without the closure the callback will always access the
281 		//last set file, which can be wrong
282 		var $this = this;
283 
284 		this.uploadChunk(chunk,file,function(index,file,cb)
285 		{
286 			var func = function(data)
287 			{
288 				file = this.getFile(file.name);
289 
290 				//we overwrite the loaded property here, because the ammount of loaded data
291 				//set in the progress event is chunkdata + header data from the xhr request.
292 				//the real amount of loaded bytes is index * chunksize
293 				//file.loaded += (index+2)*this.chunkSize;
294 				file.loaded += data.loaded;
295 
296 				file.completion = (file.loaded/file.size*100);
297 
298 				this.processFileChunkFrom(file,++index,cb);
299 			}
300 
301 			return jQuery.proxy( func, $this);
302 			//alert("yes")
303 		}(index,file,cb));
304 	},
305 
306 	/**
307 	 * @param {String} filename
308 	 * @return {Object}
309 	 */
310 	getFile : function(filename)
311 	{
312 		return this.files[filename];
313 	},
314 
315 	/**
316 	 * Uploads a chunk
317 	 * if a chunk fails we set uploadError on file object to true
318 	 * @param {Object} chunk
319 	 * @param {Object} file
320 	 * @param cb fired after successfully uploading chunk
321 	 */
322 	uploadChunk : function(chunk,file,cb)
323 	{
324 		var fd = new FormData();
325 
326 		fd.append("chunk",chunk);
327 		fd.append("filename",chunk.filename);
328 		fd.append("uid",file.uid);
329 
330 		if (chunk.index == 0)
331 		{
332 			fd.append("firstChunk",1);
333 		}
334 
335 		if ((file.loaded +this.chunkSize) >= file.size)
336 		{
337 			fd.append("lastChunk",1);
338 		}
339 
340 		var xhr = new XMLHttpRequest();
341 
342 		xhr.addEventListener("error",this.getProxy(function(e)
343 		{
344 			file.uploadError = true;
345 		}));
346 
347 		var $this = this;
348 
349 		//triggered every file state change
350 		xhr.addEventListener("readystatechange",function(file){
351 
352 			var func = function(e,f)
353 			{
354 				if (e.target.readyState == 4)
355 				{
356 					if (e.target.status == 200 || e.target.status == 0 )
357 					{
358 					}
359 					else
360 					{
361 						file.uploadError = true;
362 					}
363 				}
364 			}
365 			return jQuery.proxy( func, $this);
366 
367 		}(file));
368 
369 		var startTime = this.startTime;
370 
371 		//triggered every few ms
372 		xhr.upload.addEventListener("progress",function(file){
373 
374 			var func = function(e,f)
375 			{
376 				file = this.getFile(file.name);
377 
378 				//note that this is an indicative size. loaded bytes is complete xhr request
379 				//the actual chunk size is smaller. Bacause of that we set the actual correct size after each chunk completion
380 				//this happends in the callback in the load event
381 				var bytesLoaded = file.loaded+e.loaded;
382 
383 				var newPerc = (bytesLoaded / file.size) * 100;
384 				file.completion = Math.max(0,Math.min(newPerc,100));
385 
386 				//calculates bytes/s	//calculate eta
387 				var now =new Date().getTime();
388 
389 				file.Bps = bytesLoaded/ ((now-this.startTime)/1000);
390 
391 				file.speed = this.getReadablizeBytes(file.Bps);
392 
393 				var eta = now - this.startTime;
394 				eta = eta/file.completion * (100-file.completion);
395 
396 				//filter
397 				if (file.eta)
398 				{
399 					var alpha=0.1;
400 					eta =  eta * alpha + file.eta*(1-alpha);
401 				}
402 
403 				file.eta = eta;
404 
405 				this.triggerEvent("fileUploading",{"file":file});
406 			}
407 
408 			return jQuery.proxy( func, $this);
409 
410 
411 		}(file));
412 
413 		//triggered when chunk is complete uploading. here we call the complete callback
414 		xhr.upload.addEventListener("load",function(chunk,cb){
415 
416 			var func = function(e)
417 			{
418 				var data = {};
419 				data.loaded = chunk.size;
420 
421 				cb(data);
422 			}
423 
424 			return jQuery.proxy( func, $this);
425 
426 		}(chunk,cb));
427 
428 		xhr.open("POST",this.uploadFile);
429 		xhr.send(fd);
430 	},
431 
432 	/**
433 	 * get a more readable string by given string of bytes
434 	 * @param {String} bytes
435 	 * @return {String}
436 	 */
437 	getReadablizeBytes : function(bytes)
438 	{
439 		var s = ['bytes', 'kb', 'Mb', 'Gb', 'Tb', 'Pb'];
440 		var e = Math.floor(Math.log(bytes)/Math.log(1024));
441 		return (bytes/Math.pow(1024, Math.floor(e))).toFixed(1)+' '+s[e];
442 	}
443 });