﻿// A Chunked file uploader based on Google Gears

/*jslint white: true, browser: true, devel: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true */
/*global window: false, google: false, jQuery: false, FileReader: false */

 
// Array Remove - By John Resig (MIT Licensed)
if (!Array.prototype.remove) {
	Array.prototype.remove = function (from, to) {
		var rest = this.slice((to || from) + 1 || this.length);
		this.length = from < 0 ? this.length + from : from;
		return this.push.apply(this, rest);
	};
}

(function ($j) {	// hide jQuery variable
	"use strict";

	$j.tempest('google-gears-upload-template',
		['<div>',
				'<div class="uploads" style="padding-bottom: 1em;"></div>',
				'<div class="addUploads {{btnAlignmentClass}}"><button type="button" class="uploader-browse">{{browse}}</button></div>',
		'</div>'].join(''));
	$j.tempest('non-google-gears-upload-template',
		['<input type="hidden" name="token" />',
			'<div class="uploads" style="padding-bottom: 1em;"></div>',
			'<div class="warn" style="padding-bottom: 1em;display:none"></div>',
			'<div class="wait busy" style="padding-bottom: 1em;display:none">Uploading...</div>',
			'<div class="addUploads {{btnAlignmentClass}}"><input class="uploader-browse" type="file" name="file" value="{{browse}}"/></div>',
			'<div class="info">Upload multiple files at once, more quickly. <a href="http://gears.google.com/" target="_blank">Install Google Gears.</a></div>'
		].join(''));
	$j.tempest('FileUpload-template',
		['<div class="upload-file" id="uploadToken-{{token}}">',
			'<div class="upload-file-name"><nowrap><strong>{{file.name}}</strong></nowrap></div>',
			'<div class="upload-progress-controls layout-fixed">',
				'<div class="col-right" style="padding: 2px 4px 2px 4px; width: 64px;">',
					'<div class="layout-percent">',
						'<div class="col width-1of2 center">&nbsp;',
							'<a href=\'#\' class="icon-only control-pause upload-pause" title="{{uploader.options.language.pause}}" style="display:none;"></a>',
							'<a href=\'#\' class="icon-only control-play upload-resume" title="{{uploader.options.language.resume}}" style="display:none;"></a>',
						'</div>',
						'<div class="col width-1of2 last center">&nbsp;',
							'<a href=\'#\' class="icon-only cancel upload-cancel" title="{{uploader.options.language.cancel}}"></a>',
							'<a href=\'#\' class="icon-only remove upload-clear" title="{{uploader.options.language.clear}}" style="display: none;"></a>',
						'</div>',
					'</div>',
				'</div>',
				'<div class="col-main">',
					'<div class="upload-progress-bar-container">',
						'<div class="upload-progress-bar">&nbsp;</div>',
					'</div>',
				'</div>',
			'</div>',
			'<div class="upload-status-display">',
				'<span class="upload-status"></span>',
			'</div>',
		'</div>'].join(''));

	$j.widget("ui.uploader", {
		version: "1.0.0",
		defaults: {
			filter: null, // all files
			newUploadUrl: '/FileUpload/NewUpload.rails',
			writeChunkUrl: '/FileUpload/WriteChunk.rails',
			writeFileUrl: '/FileUpload/WriteFile.rails',
			singleFile: false,
			language: {
				title: 'Upload Files',		// Ledgend of the Fieldset
				browse: 'Browse...',		// The Browse for files button text
				pause: 'Pause',				// Pause tooltip
				resume: 'Resume Upload',	// resume tooltip
				cancel: 'Cancel Upload',	// Cancel tooltip
				clear: 'Clear Upload',		// Clear tooltip
				waiting: '{{size}}',									// status displayed when a file is waititng in the queue
				uploading: '{{size}}, {{percentage}}%, {{speed}} kb/s',	// status displayed when a file is being uploaded
				paused: '{{size}}, {{percentage}}%, Paused',			// status displayed when a file is paused
				completed: '{{size}}, Completed',						// status displayed when a file is completed
				// The error message displayed when the AJAX call to AddFiles fails
				addFilesError: 'There was an error adding files to upload. Check your internet connection and try again.',
				// The error message displayed when there is a repeated problem sending a chunk to the server
				sendChunkError: 'There was a problem sending the file to the server. Check your internet connection and try resuming the upload.',
				// The text to be displayed in the warning message that shows up if the user navigates away from the page while uploads are ongoing
				navigationMessage: 'If you do, any incomplete uploads will be canceled.',
				btnAlignmentClass: 'right'
			},
			handleNonGearsUploads: true,
			callback: function (token, file, elementSelector) {
				alert("The file '" + file.name + "' with token '" + token + "' has been uploaded. Its element selector is '" + element + "'");
			},
			onFileAdded: function (token, file) {
				console.log("File '" + file + "' (" + token + ") added to upload queue");
			},
			onFileCanceled: function (token) {
				console.log("File (" + token + ") was canceled and removed by the user");
			},
			error: function (token, file) { 
				console.log("File '" + file + "' (" + token + ") caused an error"); 
			}
		},

		_init: function () {
			this.options = $j.extend(true, this.defaults, this.options);
			this.hasGoogleGears = false;	// do we have Gears?
			this.hasW3CUploader = false;	// do we have W3C support?
			this.uploaderSupported = false;	// do we have an uploader at all?
			this.isPaused = false;			// has the user hit the pause button?
			this.suspendUploading = false;	// dont enable the browse button until the UI is fully loaded
			this.uploadQueue = [ ];			// an ordered list of Tokens that represents the upload queue
			this.uploaders = { };			// hash of uploaders by Token

			var self = this, form = this.element.closest('form'), insertUploaderAt = this.element;

			if (window.google && google.gears) {
				this.uploaderSupported = true;
				this.hasGoogleGears = true;

				// don't alter the document unless the page has loaded
				$j(function () {
					// setup an event to block navigation when uploads are in progress
					window.onbeforeunload = function () {
						if (self.uploadQueue.length > 0) {
							return self.options.language.navigationMessage;
						}
					};

					// fill the target with the Uploader UI:
					self.element.html($j.tempest('google-gears-upload-template', self.options.language));

					// bind events:				
					self.element.find('.uploader-browse').click(function () {
						self.openFilesDialog();
					});
				});
			}
			else if (self.options.handleNonGearsUploads) {
				// begining the single ajax upload
				console.log('using ajax submit');

				// are we not in a form already?
				if (form.length === 0) {
					// add our own to the DOM
					self.element.html('<form action="javascript:void(0);" onsubmit="return false;" method="post"></form>');
					form = self.element.find('form');
					insertUploaderAt = form;
				} else {
					form.attr('method', 'post');
				}

				// fill the insertUploaderAt element with the Uploader UI:
				insertUploaderAt.html($j.tempest('non-google-gears-upload-template', self.options.language));

				self.element.find('.uploader-browse').change(function () {
					var fileField, file, pos, filename, fileData;
					fileField = self.element.find('.uploader-browse')[0];

					if (fileField.files) {
						file = fileField.files[0];
					}
					else {
						pos = fileField.value.lastIndexOf("\\");
						filename = fileField.value.substring(pos + 1);
						file = { name: filename, size: -1 };
					}
					fileData = self.convertFilesArrayToData([file]);

					// Get the new upload token
					$j.ajax({
						type: "POST",
						url: self.options.newUploadUrl,
						data: { 'filesJson': JSON.stringify(fileData) },
						dataType: "json",
						success: function (tokens, textStatus) {
							// New Upload succeeded
							console.log(tokens);
							var token = tokens[0];

							// trigger the file added callback
							self.options.onFileAdded(token.token, token.fileName);
							self.element.find('input[name="token"]').val(token.token);

							// ajax submit the form identified earlier
							form.ajaxSubmit({
								iframe: true,
								url: self.options.writeFileUrl,
								type: "POST",
								resetForm: false,	// dont reset the form, it might not be ours!
								dataType: 'json',
								beforeSubmit: function (arr, form, options) {
									self.element.find('.busy').html('Uploading ' + file.name);
									self.element.find('.busy').show();
									return true;
								},
								success: function (res, stat, xhr) {
									self.element.find("div.warn").hide();
									self.element.find("div.uploads").html('<div class="upload-file" id="uploadToken-' + token.token + '">File Upload Complete</div>');
									self.options.callback(token.token, { name: file.name ? file.name : file.fileName, size: file.size ? file.size : file.fileSize }, '#uploadToken-' + token.token);
								},
								error: function (XMLHttpRequest, textStatus, errorThrown) {
									$j(this).resetForm();
									self.element.find("div.warn").html('File Upload Failed').show();
									self.options.error(token.token, token.fileName);
								},
								complete: function () {
									self.element.find('input').prop('disabled', false);
									self.element.find('.busy').hide();
								}
							});
						},
						error: function (XMLHttpRequest, textStatus, errorThrown) {
							self.options.error(null, file.name);

							console.error("Failed to get tokens from the server. Status: '", textStatus, "' Error thrown: '", errorThrown, "'");
							// alert the user of the failure:
							alert(self.options.language.addFilesError);
						}
					});
				});
			}
		},

		convertFilesArrayToData: function (files) {
			var data = [], i;
			for (i = 0; i < files.length; i += 1) {
				data[data.length] = { 
					fileName: files[i].name ? files[i].name : files[i].fileName, 
					size: files[i].size ? files[i].size : files[i].fileSize ? files[i].fileSize : files[i].blob.length   
				};
			}
			return data;
		},

		// generate tokens for new files 
		addFiles: function (files) {
			console.log("adding Files");
			if (files.length < 1) {
				return;
			}
			console.dir(files);

			var data = this.convertFilesArrayToData(files), self = this;

			$j.ajax({
				type: "POST",
				url: this.options.newUploadUrl,
				data: { 'filesJson': JSON.stringify(data) },
				dataType: "json",
				//processData: false,
				success: function (tokens, textStatus) {
					// build a hash of the file names against the file handles:
					var filesHash = {};
					$j.each(files, function () {
						filesHash[this.name] = this;
					});

					// add uploaders
					$j.each(tokens, function () {
						self.addUploader(new $j.ui.uploader.FileUpload(filesHash[this.fileName], this.token, self)); // add new FileUploads
						self.options.onFileAdded(this.token, this.fileName);
					});

					self.resumeUploads();
					self.sendNextChunk();
				},
				error: function (XMLHttpRequest, textStatus, errorThrown) {
					console.error("Failed to get tokens from the server. Status: '", textStatus, "' Error thrown: '", errorThrown, "'");
					// alert the user of the failure:
					alert(self.options.language.addFilesError);
				}
			});
		},

		// adds an uploader to the UI
		addUploader: function (fileUpload) {
			this.uploaders[fileUpload.token] = fileUpload; // add the uploader to the tracking table
			this.uploadQueue[this.uploadQueue.length] = fileUpload.token;
			fileUpload.waiting();
		},

		// sets suspendUploading state to true and dissabled the browse button
		// this state stops the user from adding files and the uploader from sending blocks while some other XHR is going on
		suspendUploads: function () {
			this.suspendUploading = true; // dont send any more chunks while processing files
			$j(this.options.target).find(".uploader-browse").prop('disabled', true);
		},

		// sets suspendUploading state to false and re-enables the browse button
		resumeUploads: function () {
			// re-enable transfers and Browse button
			this.suspendUploading = false; // ready to upload
			this._enableUploadButton();
		},

		_enableUploadButton: function () {
			// dont turn on the button unless we support multiple uploads OR nothign in the queue
			if (this.options.singleFile === false || this.uploadQueue.length === 0) {
				$j(this.options.target).find(".uploader-browse").prop('disabled', false);
			}
		},

		// brings up the File dialog and spawns uploaders for each file chosen
		openFilesDialog: function () {
			var desktop = google.gears.factory.create('beta.desktop'), options = { singleFile: this.options.singleFile }, self = this;
			if (this.options.filter) {
				if ($j.isArray(this.options.filter)) {
					options = { filter: this.options.filter };
				}
				else {
					console.error("The filter options set on the uploader is not an array!");
				}
			}

			desktop.openFiles(function (files) {
				if (files.length < 1) {
					// No Files selected
					return;
				}
				// disable the browse files button while and transfers while we process these new files
				self.suspendUploads();
				self.addFiles(files);
			}, options);
		},

		// Triggered when the user clicks the Pause button
		pause: function () {
			if (!this.isPaused) {
				this.isPaused = true;
				var uploader = this.currentUploader();
				uploader.paused();
			}
		},

		// Triggered when the user clicks the Resume button
		resume: function () {
			if (this.isPaused) {
				this.isPaused = false;
				var uploader = this.currentUploader();
				uploader.uploading();
				this.sendNextChunk();
			}
		},

		// Remove an upload from the internal queue
		removeFromQueue: function (token) {
			this.options.onFileCanceled(token);
			delete this.uploaders[token];
			this.uploadQueue.remove($j.inArray(token, this.uploadQueue));
			this._enableUploadButton(); // if in single mode this would need to be done
		},

		// Triggered when the user cancels an upload through the UI
		cancel: function (token) {
			if (this.uploaders[token]) {
				this.removeFromQueue(token);
			}
		},

		// get the current uploader object
		currentUploader: function () {
			if (this.uploadQueue.length > 0) {
				return this.uploaders[this.uploadQueue[0]];
			}
			else {
				return null;
			}
		},

		// function that sends the next chunk of the upload at the top of the queue
		sendNextChunk: function () {
			if (this.isPaused || this.suspendUploading) {
				return; // do nothing
			}

			// is the current file done?
			if (this.uploadQueue.length > 0) {
				var uploader = this.currentUploader();

				if (!uploader.isFinished()) {
					uploader.sendNextChunk();
				}
				else {
					uploader.completed();
					this.options.callback(uploader.token, uploader.file, "#uploadToken-" + uploader.token);
					this.removeFromQueue(uploader.token); // remove the item from the queue
					this.sendNextChunk(); // recursive in case the next item is also finished
				}
			}
		}
	});

	/*
	*   FileUpload - An internal class used for tracking individual uploads
	*/
	$j.ui.uploader.FileUpload = function (file, token, uploader) {
		var fileSize = (file.size ? file.size : file.blob.length), megabytes = fileSize / (1024 * 1024), self = this;
		this.file = file;
		this.token = token;		
		this.size = megabytes > 1 ? parseInt(megabytes, 10) + " MB" : parseInt(fileSize / 1024, 10) + " kB";
		this.percentage = 0;
		this.speed = 0;
		this.CHUNK_SIZE = 200 * 1024; // 256 KB chunk size
		this.MAX_FILE_SIZE = 1024 * 1024 * 1024; // 1GB - max size of a file that can be uploaded
		this.RETRY_ATTEMPTS = 3; // will retry a failed chunk 3 times before pausing the transfer
		this.retries = 0; // number of retries used
		this.currentPosition = -1; // start one back because you have to add 1 to use the slice function
		this.timeTaken = 0;
		this.uploader = uploader;

		// build the HTML for this uploader:
		this.html = $j.tempest('FileUpload-template', this);
		$j(this.uploader.element).find('.uploads').append(this.html); // add the uploader(s) to the UI
		this.element = $j('#uploadToken-' + this.token);

		// bind event handlers:
		this.element.find(".upload-cancel,.upload-clear").click(function () { 
			self.cancel(); 
			return false; 
		});
		this.element.find(".upload-pause").click(function () { 
			self.uploader.pause(); 
			return false; 
		});
		this.element.find(".upload-resume").click(function () { 
			self.uploader.resume(); 
			return false; 
		});
	};

	$j.extend($j.ui.uploader.FileUpload.prototype, {
		cancel: function () {
			this.uploader.cancel(this.token); // remove it from the queue
			// remove it from the UI
			this.element.remove();
			this.bin = null;
		},

		isFinished: function () {
			return this.currentPosition === (this.file.blob ? this.file.blob.length : this.file.size) - 1;
		},

		waiting: function (size) {
			this.element.find(".upload-status").html($j.tempest(this.uploader.options.language.waiting, this));
		},

		uploading: function () {
			this.element.find(".upload-status").html($j.tempest(this.uploader.options.language.uploading, this));
			this.element.find(".upload-progress-bar").width(this.percentage + "%");
			this.element.find(".upload-pause").show();
			this.element.find(".upload-resume").hide();
		},

		paused: function () {
			this.element.find(".upload-status").html($j.tempest(this.uploader.options.language.paused, this));
			this.element.find(".upload-pause").hide();
			this.element.find(".upload-resume").show();
		},

		completed: function () {
			this.bin = null;
			this.element.find(".upload-status").html($j.tempest(this.uploader.options.language.completed, this));
			this.element.find(".upload-pause").hide();
			this.element.find(".upload-resume").hide();
			this.element.find(".upload-cancel").hide();
			this.element.find(".upload-clear").show();
		},

		retry: function () {
			this.retries += 1; // use a re-try
			console.log("retry: ", this.retries, this.RETRY_ATTEMPTS);
			if (this.retries < this.RETRY_ATTEMPTS) {
				this.uploader.sendNextChunk(); // re-try sending this chunk
			}
			else {
				// simulate a pause for the user:
				this.uploader.pause();
				this.retries = 0;
				// alert the user
				alert(this.uploader.options.language.sendChunkError);
			}
		},

		sendNextChunk: function () {
			this.uploading();

			// compute the index bounds of the chunk to be send		
			var chunkStartIndex = this.currentPosition + 1, 
				chunkEndIndex = chunkStartIndex + this.CHUNK_SIZE - 1,
				reader,
				chunk,
				fileSize, 
				req,
				self = this,
				startTime;

			if (!this.fileSize) {
				this.fileSize = this.uploader.hasW3CUploader ? this.file.size : this.file.blob.length;
			}
			fileSize = this.fileSize;

			if (chunkEndIndex > (fileSize - 1)) {
				chunkEndIndex = fileSize - 1;
			}

			// log chunk parameters	
			//console.log("start byte:", chunkStartIndex, "end byte:", chunkEndIndex, "chunk length", chunkEndIndex - chunkStartIndex, "file length:", this.file.blob.length);

			// build request & headers
			req = this.uploader.hasW3CUploader ? new XMLHttpRequest() : google.gears.factory.create("beta.httprequest");
			req.open("POST", this.uploader.options.writeChunkUrl + '?token=' + this.token);

			// the callback
			startTime = new Date();
			req.onreadystatechange = function () {
				if (req.readyState === 4) {
					var status, json, timeTakenMs;
					// Gears seems to throw an exception if the request failed because the server is unavailable, so catch it!
					try {
						status = req.status;
					}
					catch (ex) {
						console.error("Write Chunk failed (probably a connection problem): Exception: ", ex);
						self.retry();
						return;
					}

					if (status === 200) {  // great success!
						// get JSON from response:
						json = JSON.parse(req.responseText);

						// if the response indicates there was an error:
						if (json.error) {
							console.error(json.error);
							if (json.exception) {
								console.error(json.exception);
							}
							self.retry();
							return;
						}

						if (self.uploader.isPaused || self.uploader.suspendUploading) {
							console.log('suspend ' + self.currentPosition);
							return; // dont update UI for the chunk being sent while the pause button was pressed
						}

						// update tracking
						timeTakenMs = (new Date()).getTime() - startTime.getTime();
						self.timeTaken += timeTakenMs;

						// compute doneness
						self.percentage = parseInt((chunkEndIndex / (fileSize - 1)) * 100, 10);
						self.speed = parseInt(((chunkEndIndex - chunkStartIndex) / 1024) / (timeTakenMs / 1000), 10);

						/*
						console.log("Chunk Write Successful", '
						'fileId', self.token, 
						'start', chunkStartIndex, 
						'end', chunkEndIndex,
						'timeTaken', timeTakenMs + " ms",
						'dataRate', dataRate + " kb/s",
						'Percent Completed', percentComplete + "%");
						*/
						self.uploading();

						// advance to the next chunk:
						self.currentPosition = chunkEndIndex;
						self.uploader.sendNextChunk();
					}
					else {
						// retry logic (not that you cant access the req here because it may cause exceptions)
						console.error("Write Chunk failed. HTTP Status Code: ", status, " Status Text: ", req.statusText, " Response Text: ", req.responseText);
						self.retry();
					}
				}
			};

			// send the chunk
			req.setRequestHeader('Content-Range', 'bytes ' + chunkStartIndex + '-' + chunkEndIndex + '/' + fileSize);
			req.setRequestHeader('Content-Type', 'application/octet-stream');
			req.setRequestHeader('Content-Disposition', 'attachment; filename="' + this.file.name + '"');
			req.setRequestHeader('Pragma', 'no-cache');

			if (this.uploader.hasW3CUploader) {
				if (!self.bin) {
					reader = new FileReader();
					reader.onload = function (e) {
						self.bin = e.target.result;
						self.sendNextChunk();
					};
					reader.onerror = function (e) {
						console.dir(e.target);
					};
					reader.readAsBinaryString(this.file);
					return;
				}

				chunk = this.bin.slice(chunkStartIndex, chunkEndIndex + 1);
				/*
				console.log("length: " + this.bin.length + " orig:" + fileSize);
				console.log(chunkStartIndex + " " + (chunkEndIndex));
				console.log(chunk.length);
				*/

				req.sendAsBinary(chunk);
				console.log("Pos:" + this.currentPosition);
			}
			else {
				chunk = this.file.blob.slice(chunkStartIndex, chunkEndIndex - chunkStartIndex + 1);
				req.send(chunk);
			}
		}
	});
}(jQuery));

