From 8875c94c245c82189b1e50dc09c162b0b5970aa2 Mon Sep 17 00:00:00 2001 From: Nathan Gray Date: Thu, 1 Sep 2011 22:07:30 +0000 Subject: [PATCH] Async file uploads --- .../inc/class.etemplate_widget_file.inc.php | 88 +++++++ etemplate/js/et2_widget_file.js | 78 +++--- etemplate/js/test/test.css | 24 ++ etemplate/js/test/test.php | 10 + phpgwapi/js/jquery/jquery.html5_upload.js | 222 ++++++++++++++++++ 5 files changed, 381 insertions(+), 41 deletions(-) create mode 100644 etemplate/inc/class.etemplate_widget_file.inc.php create mode 100644 phpgwapi/js/jquery/jquery.html5_upload.js diff --git a/etemplate/inc/class.etemplate_widget_file.inc.php b/etemplate/inc/class.etemplate_widget_file.inc.php new file mode 100644 index 0000000000..40e2b990fd --- /dev/null +++ b/etemplate/inc/class.etemplate_widget_file.inc.php @@ -0,0 +1,88 @@ +error("Could not read session"); + return; + } + foreach ($_FILES as $field => $file) { + if ($file['error'] == UPLOAD_ERR_OK) { + if (is_dir($GLOBALS['egw_info']['server']['temp_dir']) && is_writable($GLOBALS['egw_info']['server']['temp_dir'])) + { + $new_file = tempnam($GLOBALS['egw_info']['server']['temp_dir'],'egw_'); + } + else + { + $new_file = $value['file']['tmp_name'].'+'; + } + // Files come from ajax Base64 encoded + + $handle = fopen($new_file, 'w'); + list($prefix, $data) = explode(',', file_get_contents($file['tmp_name'])); + $file['tmp_name'] = $new_file; + fwrite($handle, base64_decode($data)); + fclose($handle); + + // Store info for future submit + $data = egw_session::appsession($request_id.'_files'); + $form_name = self::form_name($cname, $field); + $data[$form_name][] = $file; + egw_session::appsession($request_id.'_files','',$data); + } + } + } + + /** + * Validate input + * Merge any already uploaded files into the content array + * + * @param string $cname current namespace + * @param array $content + * @param array &$validated=array() validated content + */ + public function validate($cname, array $content, &$validated=array()) + { + $form_name = self::form_name($cname, $this->id); + $value = $value_in = self::get_array($content, $form_name); + $valid =& self::get_array($validated, $form_name, true); + + $files = egw_session::appsession(self::$request->id().'_files'); + $valid = $files[$form_name]; + } +} +etemplate_widget::registerWidget('etemplate_widget_file', array('file')); diff --git a/etemplate/js/et2_widget_file.js b/etemplate/js/et2_widget_file.js index b1bf6ea17f..9df8be8770 100644 --- a/etemplate/js/et2_widget_file.js +++ b/etemplate/js/et2_widget_file.js @@ -14,6 +14,7 @@ /*egw:uses et2_core_inputWidget; + phpgwapi.jquery.jquery.html5_upload; */ /** @@ -48,11 +49,27 @@ var et2_file = et2_inputWidget.extend({ } }, + asyncOptions: {}, + init: function() { this._super.apply(this, arguments) this.node = null; + // Set up the URL to have the request ID & the widget ID + var instance = this.getInstanceManager(); + + var self = this; + this.asyncOptions = { + // Callbacks + onStartOne: function(event, file_name) { return self.createStatus(event,file_name);}, + onFinishOne: function(event, response, name, number, total) { return self.finishUpload(event,response,name,number,total);}, + + sendBoundary: window.FormData || jQuery.browser.mozilla, + url: egw_json_request.prototype._assembleAjaxUrl("etemplate_widget_file::ajax_upload") + + "&request_id="+ instance.etemplate_exec_id + }; + this.asyncOptions.fieldName = this.options.id; this.createInputWidget(); }, @@ -60,8 +77,13 @@ var et2_file = et2_inputWidget.extend({ this.node = $j(document.createElement("div")).addClass("et2_file"); this.input = $j(document.createElement("input")) .attr("type", "file").attr("placeholder", this.options.blur) - .appendTo(this.node) - .change(this, this.change); + .appendTo(this.node); + + // Check for File interface, should fall back to normal form submit if missing + if(typeof File != "undefined" && typeof (new XMLHttpRequest()).upload != "undefined") + { + this.input.html5_upload(this.asyncOptions); + } this.progress = this.options.progress ? $j(document.getElementById(this.options.progress)) : $j(document.createElement("div")).appendTo(this.node); @@ -78,52 +100,26 @@ var et2_file = et2_inputWidget.extend({ }, /** - * User has selected some files file, send them off + * Creates the elements used for displaying the file, and it's upload status, and + * attaches them to the DOM */ - change: function(e) { -console.log(e); - if(!e.target.files || e.target.files.length == 0) return; - - for(var i = 0; i < e.target.files.length; i++) { - var file = e.target.files[i]; - - // Add to list - var status = this.createStatus(file); - this.progress.append(status); - - // Check if OK - if(file.size > this.options.max_file_size) { - // TODO: Try to slice into acceptable pieces - status.addClass("ui-state-error"); - this.showMessage(egw.lang("File is too large."), "validation_error"); - var self = this; - window.setTimeout(function() {self.hideMessage(true); status.remove();}, 5000); - } + createStatus: function(event, file_name) { + if(this.progress) + { + $j("
  • "+file_name+"

  • ").appendTo(this.progress); } - - // Reset field - e.data.input.attr("type", "input").attr("type","file"); + return true; }, /** - * Upload a single file asyncronously + * A file upload is finished, update the UI */ - _upload_file: function(file) { - // Start upload - console.log(this, file); - - }, - - /** - * Creates the elements used for displaying the file, and it's upload status - * - * @param file A JS File object - * @return a jQuery object with the elements - */ - createStatus: function(file) { - return $j("
  • "+file.name+"

  • "); + finishUpload: function(event, response, name, number, total) { + if(this.progress) + { + $j("[file='"+name+"']",this.progress).addClass("upload_finished"); + } } - }); et2_register_widget(et2_file, ["file"]); diff --git a/etemplate/js/test/test.css b/etemplate/js/test/test.css index 203c1c70a8..3ee4fa54f4 100644 --- a/etemplate/js/test/test.css +++ b/etemplate/js/test/test.css @@ -190,6 +190,30 @@ span.et2_date span { font-size: 9pt; } +/** + * File upload + */ +.et2_file .progress { + max-height: 6em; + overflow: auto; + margin-left: 20px; + + -moz-column-count: 4; + -moz-column-gap: 20px; + -webkit-column-count: 4; + -webkit-column-gap: 20px; + column-count: 4; + column-gap: 20px; + +} + +.et2_file .progress li { + color: blue; +} +.et2_file .progress li.upload_finished { + color: green; +} + .egw_tooltip { position: fixed; diff --git a/etemplate/js/test/test.php b/etemplate/js/test/test.php index df4e9a3371..d66fe751d2 100644 --- a/etemplate/js/test/test.php +++ b/etemplate/js/test/test.php @@ -16,8 +16,18 @@ include('../../../header.inc.php'); egw_framework::validate_file('.','etemplate2','etemplate'); egw_framework::validate_file('jquery','jquery.tools.min','phpgwapi'); // Not needed once JS require works for files like this + egw_framework::validate_file('jquery','jquery.html5_upload','phpgwapi'); // Not needed once JS require works for files like this egw_framework::includeCSS('/etemplate/js/test/test.css'); +/* +* Test using any actual template +*/ +// $template = 'etemplate.et2_test_file_upload'; + if($template) { + $etemplate = new etemplate_new('etemplate.et2_test_file_upload'); + $etemplate->exec('',array()); + return; + } common::egw_header(); // parse_navbar(); ?> diff --git a/phpgwapi/js/jquery/jquery.html5_upload.js b/phpgwapi/js/jquery/jquery.html5_upload.js new file mode 100644 index 0000000000..a648c7ba93 --- /dev/null +++ b/phpgwapi/js/jquery/jquery.html5_upload.js @@ -0,0 +1,222 @@ +(function($) { + jQuery.fn.html5_upload = function(options) { + + var available_events = ['onStart', 'onStartOne', 'onProgress', 'onFinishOne', 'onFinish', 'onError']; + var options = jQuery.extend({ + onStart: function(event, total) { + return true; + }, + onStartOne: function(event, name, number, total) { + return true; + }, + onProgress: function(event, progress, name, number, total) { + }, + onFinishOne: function(event, response, name, number, total) { + }, + onFinish: function(event, total) { + }, + onError: function(event, name, error) { + }, + onBrowserIncompatible: function() { + alert("Sorry, but your browser is incompatible with uploading files using HTML5 (at least, with current preferences.\n Please install the latest version of Firefox, Safari or Chrome"); + }, + autostart: true, + autoclear: true, + stopOnFirstError: false, + sendBoundary: false, + fieldName: 'user_file[]',//ignore if sendBoundary is false + method: 'post', + + STATUSES: { + 'STARTED': 'Запуск', + 'PROGRESS': 'Загрузка', + 'LOADED': 'Обработка', + 'FINISHED': 'Завершено' + }, + headers: { + "Cache-Control":"no-cache", + "X-Requested-With":"XMLHttpRequest", + "X-File-Name": function(file){return file.fileName}, + "X-File-Size": function(file){return file.fileSize}, + "Content-Type": function(file){ + if (!options.sendBoundary) return 'multipart/form-data'; + return false; + } + }, + + + setName: function(text) {}, + setStatus: function(text) {}, + setProgress: function(value) {}, + + genName: function(file, number, total) { + return file + "(" + (number+1) + " из " + total + ")"; + }, + genStatus: function(progress, finished) { + if (finished) { + return options.STATUSES['FINISHED']; + } + if (progress == 0) { + return options.STATUSES['STARTED']; + } + else if (progress == 1) { + return options.STATUSES['LOADED']; + } + else { + return options.STATUSES['PROGRESS']; + } + }, + genProgress: function(loaded, total) { + return loaded / total; + } + }, options); + + function upload() { + var files = this.files; + var total = files.length; + var $this = $(this); + if (!$this.triggerHandler('html5_upload.onStart', [total])) { + return false; + } + this.disabled = true; + var uploaded = 0; + var xhr = this.html5_upload['xhr']; + this.html5_upload['continue_after_abort'] = true; + function upload_file(number) { + if (number == total) { + $this.trigger('html5_upload.onFinish', [total]); + options.setStatus(options.genStatus(1, true)); + $this.attr("disabled", false); + if (options.autoclear) { + $this.val(""); + } + return; + } + var file = files[number]; + if (!$this.triggerHandler('html5_upload.onStartOne', [file.fileName, number, total])) { + return upload_file(number+1); + } + options.setStatus(options.genStatus(0)); + options.setName(options.genName(file.fileName, number, total)); + options.setProgress(options.genProgress(0, file.fileSize)); + xhr.upload['onprogress'] = function(rpe) { + $this.trigger('html5_upload.onProgress', [rpe.loaded / rpe.total, file.fileName, number, total]); + options.setStatus(options.genStatus(rpe.loaded / rpe.total)); + options.setProgress(options.genProgress(rpe.loaded, rpe.total)); + }; + xhr.onload = function(load) { + $this.trigger('html5_upload.onFinishOne', [xhr.responseText, file.fileName, number, total]); + options.setStatus(options.genStatus(1, true)); + options.setProgress(options.genProgress(file.fileSize, file.fileSize)); + upload_file(number+1); + }; + xhr.onabort = function() { + if ($this[0].html5_upload['continue_after_abort']) { + upload_file(number+1); + } + else { + $this.attr("disabled", false); + if (options.autoclear) { + $this.val(""); + } + } + }; + xhr.onerror = function(e) { + $this.trigger('html5_upload.onError', [file.fileName, e]); + if (!options.stopOnFirstError) { + upload_file(number+1); + } + }; + xhr.open(options.method, typeof(options.url) == "function" ? options.url(number) : options.url, true); + $.each(options.headers,function(key,val){ + val = typeof(val) == "function" ? val(file) : val; // resolve value + if (val === false) return true; // if resolved value is boolean false, do not send this header + xhr.setRequestHeader(key, val); + }); + + if (!options.sendBoundary) { + xhr.send(file); + } + else { + if (window.FormData) {//Many thanks to scottt.tw + var f = new FormData(); + f.append(typeof(options.fieldName) == "function" ? options.fieldName() : options.fieldName, file); + xhr.send(f); + } + else if (file.getAsBinary) {//Thanks to jm.schelcher + var boundary = '------multipartformboundary' + (new Date).getTime(); + var dashdash = '--'; + var crlf = '\r\n'; + + /* Build RFC2388 string. */ + var builder = ''; + + builder += dashdash; + builder += boundary; + builder += crlf; + + builder += 'Content-Disposition: form-data; name="'+(typeof(options.fieldName) == "function" ? options.fieldName() : options.fieldName)+'"'; + + //thanks to oyejo...@gmail.com for this fix + fileName = unescape(encodeURIComponent(file.fileName)); //encode_utf8 + + builder += '; filename="' + fileName + '"'; + builder += crlf; + + builder += 'Content-Type: application/octet-stream'; + builder += crlf; + builder += crlf; + + /* Append binary data. */ + builder += file.getAsBinary(); + builder += crlf; + + /* Write boundary. */ + builder += dashdash; + builder += boundary; + builder += dashdash; + builder += crlf; + + xhr.setRequestHeader('content-type', 'multipart/form-data; boundary=' + boundary); + xhr.sendAsBinary(builder); + } + else { + options.onBrowserIncompatible(); + } + } + } + upload_file(0); + return true; + } + + return this.each(function() { + this.html5_upload = { + xhr: new XMLHttpRequest(), + continue_after_abort: true + }; + if (options.autostart) { + $(this).bind('change', upload); + } + for (event in available_events) { + if (options[available_events[event]]) { + $(this).bind("html5_upload."+available_events[event], options[available_events[event]]); + } + } + $(this) + .bind('html5_upload.start', upload) + .bind('html5_upload.cancelOne', function() { + this.html5_upload['xhr'].abort(); + }) + .bind('html5_upload.cancelAll', function() { + this.html5_upload['continue_after_abort'] = false; + this.html5_upload['xhr'].abort(); + }) + .bind('html5_upload.destroy', function() { + this.html5_upload['continue_after_abort'] = false; + this.xhr.abort(); + delete this.html5_upload; + $(this).unbind('html5_upload.*').unbind('change', upload); + }); + }); + }; +})(jQuery);