﻿/*
 * A Chunked file uploader based on Google Gears
 */
 
// 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
	$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.'
			},
			handleNonGearsUploads: false,
			callback: function(token, file, element) {
				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);
			var self = this;
			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

			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(self.ui.evaluate(self.options.language));

					// bind events:				
					self.element.find("form.upload").submit(function() { self.openFilesDialog(); return false; });
				});
			} else if (self.options.handleNonGearsUploads) {

				// begining the single ajax upload
				console.log('using ajax submit');

				// fill the target with the Uploader UI:
				self.element.html(self.ui2.evaluate(self.options.language));


				self.element.find("form.upload").find('.browseButton').change(function() {

					var fileField = self.element.find("form.upload").find('.browseButton')[0];

					if (fileField.files) {

						var file = self.element.find("form.upload").find('.browseButton')[0].files[0];
					} else {
						var pos = fileField.value.lastIndexOf("\\");
						var filename = fileField.value.substring(pos + 1);
						var file = { name: filename, size: -1 };

					}
					var filedata = self.convertFilesArrayToData([file]);

					// Get the new upload token
					$j.ajax({
						type: "POST",
						url: self.options.newUploadUrl,
						data: { 'filesJson': filedata.toJSON() },
						dataType: "json",
						//processData: false,
						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("form.upload").find('input[name=token]').val(token.token);

							self.element.find("form.upload").ajaxSubmit({
								iframe: true,
								url: self.options.writeFileUrl,
								type: "POST",
								resetForm: true,
								dataType: 'json',
								beforeSubmit: function(arr, form, options) {
									self.element.find("form.upload").find('.busy').html('Uploading ' + file.name);
									self.element.find("form.upload").find('.busy').show();
									return true;
								},
								success: function(res, stat, xhr) {
									self.element.find("div.warn").hide();
									self.options.callback(token.token, { name: file.name ? file.name : file.fileName, size: file.size ? file.size : file.fileSize }, self.element.find("div.notices").show());
								},
								error: function() {
									$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("form.upload").find('input').attr('disabled', '');
									self.element.find("form.upload").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);
						}
					});

				});


			}            
		},

		// this template is used for the google gears uploads
		ui: new Template(
			['<form class="upload" action="#">',
					'<fieldset>',
					'<legend>#{title}</legend>',
					'<div class="uploads" style="padding-bottom: 1em;"></div>',
					'<div class="addUploads right"><input class="browseButton" type="submit" name="openFiles" value="#{browse}"/></div>',
					'</fieldset>',
				'</form>'
			].join('')
		),

		// This UI is used for standard form single file upload
		ui2: new Template(
			['<form class="upload" action="#">',
					'<fieldset>',
					'<legend>#{title}</legend><input type="hidden" name="token" />',
					'<div class="warn" style="padding-bottom: 1em;display:none"></div><div class="wait busy" style="padding-bottom: 1em;display:none">Uploading...</div><div class="notices" style="padding-bottom: 1em;display:none"></div>',
					'<div class="addUploads right"><input class="browseButton" 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></fieldset>',
				'</form>'
			].join('')
		),

		convertFilesArrayToData: function(files) {
			var data = [];
			for (var 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);


			var self = this;
			$j.ajax({
				type: "POST",
				url: this.options.newUploadUrl,
				data: { 'filesJson': data.toJSON() },
				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
		// thisstate 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(".browseButton").attr('disabled', 'disabled');
		},

		// 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(".browseButton").removeAttr('disabled');
		},

		// brings up the File dialog and spawns uploaders for each file chosen
		openFilesDialog: function() {
			var desktop = google.gears.factory.create('beta.desktop');
			var options = { singleFile: this.options.singleFile };
			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!");
			}

			var self = this;
			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) {
		this.file = file;
		this.token = token;
		var fileSize = (file.size ? file.size : file.blob.length);
		var megabytes = fileSize / (1024 * 1024);
		this.size = megabytes > 1 ? parseInt(megabytes) + " MB" : parseInt(fileSize / 1024) + " 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 = this.ui.evaluate(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:
		var self = this;
		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, {
		ui: new 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;',
									'<span class="icon-only control-pause upload-pause" title="#{uploader.options.language.pause}" style="display:none;"></span>',
									'<span class="icon-only control-play upload-resume" title="#{uploader.options.language.resume}" style="display:none;"></span>',
								'</div>',
								'<div class="col width-1of2 last center">&nbsp;',
									'<span class="icon-only cancel upload-cancel" title="#{uploader.options.language.cancel}"></span>',
									'<span class="icon-only remove upload-clear" title="#{uploader.options.language.clear}" style="display: none;"></span>',
								'</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('')
		),

		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((new Template(this.uploader.options.language.waiting)).evaluate(this));
		},

		uploading: function() {
			this.element.find(".upload-status").html((new Template(this.uploader.options.language.uploading)).evaluate(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((new Template(this.uploader.options.language.paused)).evaluate(this));
			this.element.find(".upload-pause").hide();
			this.element.find(".upload-resume").show();
		},

		completed: function() {
			this.bin = null;
			this.element.find(".upload-status").html((new Template(this.uploader.options.language.completed)).evaluate(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, self.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;
			var chunkEndIndex = chunkStartIndex + this.CHUNK_SIZE - 1;
			if (!this.fileSize) {
				this.fileSize = this.uploader.hasW3CUploader ? this.file.size : this.file.blob.length;
			}
			var 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
			if (this.uploader.hasW3CUploader)
				var req = new XMLHttpRequest();
			else
				var req = google.gears.factory.create("beta.httprequest");

			req.open("POST", this.uploader.options.writeChunkUrl + '?token=' + this.token);

			// the callback
			var self = this;
			var startTime = new Date();
			req.onreadystatechange = function() {
				if (req.readyState === 4) {
					var status;
					// 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:
						var 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(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
						var 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) {
					var 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;
				}

				var 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 {
				var chunk = this.file.blob.slice(chunkStartIndex, chunkEndIndex - chunkStartIndex + 1);
				req.send(chunk);
			}
		}
	});

})(jQuery)

