diff --git a/css/filedrop.css b/css/filedrop.css new file mode 100644 index 0000000000000000000000000000000000000000..3f617b2d7454096be158d3ba5e237304695a1f6b --- /dev/null +++ b/css/filedrop.css @@ -0,0 +1,147 @@ +.filedrop .dropzone { + border: 2px dashed rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 5px; + background-color: rgba(0, 0, 0, 0.05); + color: #999; +} +.filedrop .dropzone a { + color: rgba(24, 78, 129, 0.7); +} +.filedrop .files:not(:empty) { + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 5px 5px 0 0; + padding: 5px; +} +.filedrop .files:not(:empty) + .dropzone { + border-top: none; + border-radius: 0 0 5px 5px; +} +.filedrop .files .file { + display: block; + padding: 5px 10px 5px 20px; + margin: 0; + border-radius: 5px; +} +.filedrop .files .file:hover { + background-color: rgba(0, 0, 0, 0.05); +} +.filedrop .files .file .filesize { + margin-left: 1em; + color: #999; +} +.filedrop .files .file .upload-rate { + margin-right: 10px; + color: #aaa; +} +.filedrop .files .file .trash { + cursor: pointer; +} + +/* Bootstrap 3.2 progress-bar */ +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@-o-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +.progress { + height: 10px; + overflow: hidden; + background-color: #f5f5f5; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); +} +.progress-bar { + float: left; + width: 0; + height: 100%; + font-size: 12px; + line-height: 20px; + color: #fff; + text-align: center; + background-color: #428bca; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + -webkit-transition: width .6s ease; + -o-transition: width .6s ease; + transition: width .6s ease; +} +.progress-striped .progress-bar, +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + -webkit-background-size: 40px 40px; + background-size: 40px 40px; +} +.progress.active .progress-bar, +.progress-bar.active { + -webkit-animation: progress-bar-stripes 2s linear infinite; + -o-animation: progress-bar-stripes 2s linear infinite; + animation: progress-bar-stripes 2s linear infinite; +} +.progress-bar[aria-valuenow="1"], +.progress-bar[aria-valuenow="2"] { + min-width: 30px; +} +.progress-bar[aria-valuenow="0"] { + min-width: 30px; + color: #777; + background-color: transparent; + background-image: none; + -webkit-box-shadow: none; + box-shadow: none; +} +.progress-bar-success { + background-color: #5cb85c; +} +.progress-striped .progress-bar-success { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-info { + background-color: #5bc0de; +} +.progress-striped .progress-bar-info { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-warning { + background-color: #f0ad4e; +} +.progress-striped .progress-bar-warning { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-danger { + background-color: #d9534f; +} +.progress-striped .progress-bar-danger { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} + diff --git a/include/ajax.forms.php b/include/ajax.forms.php index 84b2cb3d8da8302fa1ed1fd0ad9600ea8b8fcf4a..cbd0ae46e20b377697f1cd63ea186b43656f76c2 100644 --- a/include/ajax.forms.php +++ b/include/ajax.forms.php @@ -2,6 +2,7 @@ require_once(INCLUDE_DIR . 'class.topic.php'); require_once(INCLUDE_DIR . 'class.dynamic_forms.php'); +require_once(INCLUDE_DIR . 'class.forms.php'); class DynamicFormsAjaxAPI extends AjaxController { function getForm($form_id) { @@ -81,5 +82,22 @@ class DynamicFormsAjaxAPI extends AjaxController { Http::response(201, 'Successfully updated record'); } + + function upload($id) { + if (!$field = DynamicFormField::lookup($id)) + Http::response(400, 'No such field'); + + $impl = $field->getImpl(); + if (!$impl instanceof FileUploadField) + Http::response(400, 'Upload to a non file-field'); + + return $impl->upload(); + } + + function attach() { + $field = new FileUploadField(); + $field->loadSystemDefaultConfig(); + return $field->upload(); + } } ?> diff --git a/include/class.forms.php b/include/class.forms.php index a20b330929df8c4a38a972971c2ae2f2247d8f8d..7f8a0f935100bc00143fa0bad871827e76f57bc6 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -168,6 +168,7 @@ class FormField { 'phone' => array( /* @trans */ 'Phone Number', 'PhoneField'), 'bool' => array( /* @trans */ 'Checkbox', 'BooleanField'), 'choices' => array( /* @trans */ 'Choices', 'ChoiceField'), + 'files' => array( /* @trans */ 'File Upload', 'FileUploadField'), 'break' => array( /* @trans */ 'Section Break', 'SectionBreakField'), ), ); @@ -1206,6 +1207,145 @@ FormField::addFieldTypes('Dynamic Fields', function() { ); }); +class FileUploadField extends FormField { + static $widget = 'FileUploadWidget'; + + protected $attachments; + + function getConfigurationOptions() { + // Compute size selections + $sizes = array('262144' => '— Small —'); + $next = 512 << 10; + $max = strtoupper(ini_get('upload_max_filesize')); + $limit = (int) $max; + if (!$limit) $limit = 2 << 20; # 2M default value + elseif (strpos($max, 'K')) $limit <<= 10; + elseif (strpos($max, 'M')) $limit <<= 20; + elseif (strpos($max, 'G')) $limit <<= 30; + while ($next <= $limit) { + // Select the closest, larger value (in case the + // current value is between two) + $diff = $next - $config['max_file_size']; + $sizes[$next] = Format::file_size($next); + $next *= 2; + } + // Add extra option if top-limit in php.ini doesn't fall + // at a power of two + if ($next < $limit * 2) + $sizes[$limit] = Format::file_size($limit); + + return array( + 'size' => new ChoiceField(array( + 'label'=>'Maximum File Size', + 'hint'=>'Maximum size of a single file uploaded to this field', + 'default'=>'262144', + 'choices'=>$sizes + )), + 'extensions' => new TextareaField(array( + 'label'=>'Allowed Extensions', + 'hint'=>'Enter allowed file extensions separated by a comma. + e.g .doc, .pdf. To accept all files enter wildcard + <b><i>.*</i></b> — i.e dotStar (NOT Recommended).', + 'default'=>'.doc, .pdf, .jpg, .jpeg, .gif, .png, .xls, .docx, .xlsx, .txt', + 'configuration'=>array('html'=>false, 'rows'=>2), + )), + 'max' => new TextboxField(array( + 'label'=>'Maximum Files', + 'hint'=>'Users cannot upload more than this many files', + 'default'=>false, + 'required'=>false, + 'validator'=>'number', + 'configuration'=>array('size'=>4, 'length'=>4), + )) + ); + } + + function loadSystemDefaultConfig() { + global $cfg; + $this->_config = array( + 'max' => $cfg->getStaffMaxFileUploads(), + 'size' => $cfg->getMaxFileSize(), + 'extensions' => $cfg->getAllowedFileTypes(), + ); + } + + function upload() { + $config = $this->getConfiguration(); + + $files = AttachmentFile::format($_FILES['upload'], + // For numeric fields assume configuration exists + is_numeric($this->get('id'))); + if (count($files) != 1) + Http::response(400, 'Send one file at a time'); + $file = array_shift($files); + $file['name'] = urldecode($file['name']); + + // TODO: Check allowed type / size. + // Return HTTP/413, 415, 417 or similar + + if (!($id = AttachmentFile::upload($file))) + Http::response(500, 'Unable to store file'); + + Http::response(200, $id); + } + + function getFiles() { + if (!isset($this->attachments) && ($a = $this->getAnswer()) + && ($e = $a->getEntry()) && ($e->get('id')) + ) { + $this->attachments = new GenericAttachments( + // Combine the field and entry ids to make the key + sprintf('%u', crc32('E'.$this->get('id').$e->get('id'))), + 'E'); + } + return $this->attachments ? $this->attachments->getAll() : array(); + } + + // When the field is saved to database, encode the ID listing as a json + // array. Then, inspect the difference between the files actually + // attached to this field + function to_database($value) { + $this->getFiles(); + if (isset($this->attachments)) { + $ids = array(); + // Handle deletes + foreach ($this->attachments->getAll() as $f) { + if (!in_array($f['id'], $value)) + $this->attachments->delete($f['id']); + else + $ids[] = $f['id']; + } + // Handle new files + foreach ($value as $id) { + if (!in_array($id, $ids)) + $this->attachments->upload($id); + } + } + return JsonDataEncoder::encode($value); + } + + function parse($value) { + // Values in the database should be integer file-ids + return array_map(function($e) { return (int) $e; }, + $value ?: array()); + } + + function to_php($value) { + return JsonDataParser::decode($value); + } + + function display($value) { + $links = array(); + foreach ($this->getFiles() as $f) { + $hash = strtolower($f['key'] + . md5($f['id'].session_id().strtolower($f['key']))); + $links[] = sprintf('<a class="no-pjax" href="file.php?h=%s">%s</a>', + $hash, Format::htmlchars($f['name'])); + } + return implode('<br/>', $links); + } +} + class Widget { static $media = null; @@ -1568,4 +1708,69 @@ class ThreadEntryWidget extends Widget { } } +class FileUploadWidget extends Widget { + static $media = array( + 'js' => array( + '/js/filedrop.field.js' + ), + 'css' => array( + '/css/filedrop.css', + ), + ); + + function render($how) { + $config = $this->field->getConfiguration(); + $name = $this->field->getFormName(); + $attachments = $this->field->getFiles(); + $files = array(); + foreach ($this->value ?: array() as $id) { + $found = false; + foreach ($attachments as $f) { + if ($f['id'] == $id) { + $files[] = $f; + $found = true; + break; + } + } + if (!$found && ($file = AttachmentFile::lookup($id))) { + $files[] = array( + 'id' => $file->getId(), + 'name' => $file->getName(), + 'type' => $file->getType(), + 'size' => $file->getSize(), + ); + } + } + ?><div id="filedrop-<?php echo $name; + ?>" class="filedrop"><div class="files"></div> + <div class="dropzone"><i class="icon-upload"></i> + Drop files here or <a href="#" class="manual">choose + them</a></div></div> + <input type="file" id="file-<?php echo $name; ?>" style="display:none;"/> + <script type="text/javascript"> + $(function(){$('#filedrop-<?php echo $name; ?> .dropzone').filedropbox({ + url: 'ajax.php/form/upload/<?php echo $this->field->get('id') ?>', + link: $('#filedrop-<?php echo $name; ?>').find('a.manual'), + paramname: 'upload[]', + fallback_id: 'file-<?php echo $name; ?>', + allowedfileextensions: '<?php echo $config['extensions']; + ?>'.split(/,\s*/), + maxfiles: <?php echo $config['max'] ?: 20; ?>, + maxfilesize: <?php echo $config['filesize'] ?: 1048576 / 1048576; ?>, + name: '<?php echo $name; ?>[]', + files: <?php echo JsonDataEncoder::encode($files); ?> + });}); + </script> +<?php + } + + function getValue() { + $data = $this->field->getSource(); + // If no value was sent, assume an empty list + if ($data && is_array($data) && !isset($data[$this->name])) + return array(); + return parent::getValue(); + } +} + ?> diff --git a/js/filedrop.field.js b/js/filedrop.field.js new file mode 100644 index 0000000000000000000000000000000000000000..5b02495276d0075d8b082ee33f5a852221699c6b --- /dev/null +++ b/js/filedrop.field.js @@ -0,0 +1,684 @@ +!function($) { + "use strict"; + + var FileDropbox = function(element, options) { + this.$element = $(element); + this.uploads = []; + + var events = { + uploadStarted: $.proxy(this.uploadStarted, this), + uploadFinished: $.proxy(this.uploadFinished, this), + progressUpdated: $.proxy(this.progressUpdated, this), + speedUpdated: $.proxy(this.speedUpdated, this), + dragOver: $.proxy(this.dragOver, this), + drop: $.proxy(this.dragLeave, this), + error: $.proxy(this.handleError, this) + }; + + this.options = $.extend({}, $.fn.filedropbox.defaults, events, options); + this.$element.filedrop(this.options); + (this.options.files || []).forEach($.proxy(this.addNode, this)); + }; + + FileDropbox.prototype = { + drop: function(e) { + this.$element.removeAttr('style'); + }, + dragOver: function(box, e) { + this.$element.css('background-color', 'rgba(0, 0, 0, 0.3)'); + }, + speedUpdated: function(i, file, speed) { + var that = this; + this.uploads.some(function(e) { + if (e.data('file') == file) { + e.find('.upload-rate').text(that.fileSize(speed * 1024)+'/s'); + return true; + } + }); + }, + progressUpdated: function(i, file, value) { + this.uploads.some(function(e) { + if (e.data('file') == file) { + e.find('.progress').show(); + e.find('.progress-bar') + .width(value + '%') + .attr({'aria-valuenow': value}) + if (value > 99) + e.find('.progress-bar').addClass('progress-bar-striped active') + return true; + } + }); + }, + uploadStarted: function(i, file, n) { + var node = this.addNode(file).data('file', file); + node.find('.trash').hide(); + this.uploads.push(node); + this.progressUpdated(i, file, 0); + }, + uploadFinished: function(i, file, response, time, xhr) { + var that = this; + this.uploads.some(function(e) { + if (e.data('file') == file) { + e.find('[name="'+that.options.name+'"]').val(response); + e.find('.progress-bar') + .width('100%') + .attr({'aria-valuenow': 100}) + e.find('.trash').show(); + e.find('.upload-rate').hide(); + setTimeout(function() { e.find('.progress').hide(); }, 600); + return true; + } + }); + }, + fileSize: function(size) { + var sizes = ['k','M','G','T'], + suffix = ''; + while (size > 900) { + size /= 1024; + suffix = sizes.shift(); + } + return size.toPrecision(3) + suffix + 'B'; + }, + addNode: function(file) { + var filenode = $('<div class="file"></div>') + .append($('<div class="filetype"></div>').addClass()) + .append($('<div class="filename"></div>').text(file.name) + .append($('<span class="filesize"></span>').text( + this.fileSize(file.size) + )).append($('<div class="upload-rate pull-right"></div>')) + ).append($('<div class="progress"></div>') + .append($('<div class="progress-bar"></div>')) + .attr({'aria-valuemin':0,'aria-valuemax':100}) + .hide()) + .append($('<input type="hidden"/>').attr('name', this.options.name) + .val(file.id)); + if (this.options.deletable) { + filenode.prepend($('<span><i class="icon-trash"></i></span>') + .addClass('trash pull-right') + .click($.proxy(this.deleteNode, this, filenode)) + ); + } + this.$element.parent().find('.files').append(filenode); + return filenode; + }, + deleteNode: function(filenode, e) { + if (confirm(__('You sure?'))) + filenode.remove(); + } + }; + + $.fn.filedropbox = function ( option ) { + return this.each(function () { + var $this = $(this), + data = $this.data('dropbox'), + options = typeof option == 'object' && option; + if (!data) $this.data('dropbox', (data = new FileDropbox(this, options))); + if (typeof option == 'string') data[option](); + }); + }; + + $.fn.filedropbox.defaults = { + files: [], + deletable: true + }; + + $.fn.filedropbox.Constructor = FileDropbox; + +}(jQuery); + +/* + * Default text - jQuery plugin for html5 dragging files from desktop to browser + * + * Author: Weixi Yen + * + * Email: [Firstname][Lastname]@gmail.com + * + * Copyright (c) 2010 Resopollution + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + * + * Project home: + * http://www.github.com/weixiyen/jquery-filedrop + * + * Version: 0.1.0 + * + * Features: + * Allows sending of extra parameters with file. + * Works with Firefox 3.6+ + * Future-compliant with HTML5 spec (will work with Webkit browsers and IE9) + * Usage: + * See README at project homepage + * + */ +;(function($) { + + jQuery.event.props.push("dataTransfer"); + + var default_opts = { + fallback_id: '', + link: false, + url: '', + refresh: 1000, + paramname: 'userfile', + requestType: 'POST', // just in case you want to use another HTTP verb + allowedfileextensions:[], + allowedfiletypes:[], + maxfiles: 25, // Ignored if queuefiles is set > 0 + maxfilesize: 1, // MB file size limit + queuefiles: 0, // Max files before queueing (for large volume uploads) + queuewait: 200, // Queue wait time if full + data: {}, + headers: {}, + drop: empty, + dragStart: empty, + dragEnter: empty, + dragOver: empty, + dragLeave: empty, + docEnter: empty, + docOver: empty, + docLeave: empty, + beforeEach: empty, + afterAll: empty, + rename: empty, + error: function(err, file, i, status) { + alert(err); + }, + uploadStarted: empty, + uploadFinished: empty, + progressUpdated: empty, + globalProgressUpdated: empty, + speedUpdated: empty + }, + errors = ["BrowserNotSupported", "TooManyFiles", "FileTooLarge", "FileTypeNotAllowed", "NotFound", "NotReadable", "AbortError", "ReadError", "FileExtensionNotAllowed"]; + + $.fn.filedrop = function(options) { + var opts = $.extend({}, default_opts, options), + global_progress = [], + doc_leave_timer, stop_loop = false, + files_count = 0, + files; + + $('#' + opts.fallback_id).css({ + display: 'none', + width: 0, + height: 0 + }); + + this.on('drop', drop).on('dragstart', opts.dragStart).on('dragenter', dragEnter).on('dragover', dragOver).on('dragleave', dragLeave); + $(document).on('drop', docDrop).on('dragenter', docEnter).on('dragover', docOver).on('dragleave', docLeave); + + (opts.link || this).on('click', function(e){ + $('#' + opts.fallback_id).trigger(e); + }); + + $('#' + opts.fallback_id).change(function(e) { + opts.drop(e); + files = e.target.files; + files_count = files.length; + upload(); + }); + + function drop(e) { + if( opts.drop.call(this, e) === false ) return false; + if(!e.dataTransfer) + return; + files = e.dataTransfer.files; + if (files === null || files === undefined || files.length === 0) { + opts.error(errors[0]); + return false; + } + files_count = files.length; + upload(); + e.preventDefault(); + return false; + } + + function getBuilder(filename, filedata, mime, boundary) { + var dashdash = '--', + crlf = '\r\n', + builder = '', + paramname = opts.paramname; + + if (opts.data) { + var params = $.param(opts.data).replace(/\+/g, '%20').split(/&/); + + $.each(params, function() { + var pair = this.split("=", 2), + name = decodeURIComponent(pair[0]), + val = decodeURIComponent(pair[1]); + + if (pair.length !== 2) { + return; + } + + builder += dashdash; + builder += boundary; + builder += crlf; + builder += 'Content-Disposition: form-data; name="' + name + '"'; + builder += crlf; + builder += crlf; + builder += val; + builder += crlf; + }); + } + + if (jQuery.isFunction(paramname)){ + paramname = paramname(filename); + } + + builder += dashdash; + builder += boundary; + builder += crlf; + builder += 'Content-Disposition: form-data; name="' + (paramname||"") + '"'; + builder += '; filename="' + encodeURIComponent(filename) + '"'; + builder += crlf; + + builder += 'Content-Type: ' + mime; + builder += crlf; + builder += crlf; + + builder += filedata; + builder += crlf; + + builder += dashdash; + builder += boundary; + builder += dashdash; + builder += crlf; + return builder; + } + + function progress(e) { + if (e.lengthComputable) { + var percentage = Math.round((e.loaded * 100) / e.total); + if (this.currentProgress !== percentage) { + + this.currentProgress = percentage; + opts.progressUpdated(this.index, this.file, this.currentProgress); + + global_progress[this.global_progress_index] = this.currentProgress; + globalProgress(); + + var elapsed = new Date().getTime(); + var diffTime = elapsed - this.currentStart; + if (diffTime >= opts.refresh) { + var diffData = e.loaded - this.startData; + var speed = diffData / diffTime; // KB per second + opts.speedUpdated(this.index, this.file, speed); + this.startData = e.loaded; + this.currentStart = elapsed; + } + } + } + } + + function globalProgress() { + if (global_progress.length === 0) { + return; + } + + var total = 0, index; + for (index in global_progress) { + if(global_progress.hasOwnProperty(index)) { + total = total + global_progress[index]; + } + } + + opts.globalProgressUpdated(Math.round(total / global_progress.length)); + } + + // Respond to an upload + function upload() { + stop_loop = false; + + if (!files) { + opts.error(errors[0]); + return false; + } + + if (opts.allowedfiletypes.push && opts.allowedfiletypes.length) { + for(var fileIndex = files.length;fileIndex--;) { + if(!files[fileIndex].type || $.inArray(files[fileIndex].type, opts.allowedfiletypes) < 0) { + opts.error(errors[3], files[fileIndex]); + return false; + } + } + } + + if (opts.allowedfileextensions.push && opts.allowedfileextensions.length) { + for(var fileIndex = files.length;fileIndex--;) { + var allowedextension = false; + for (i=0;i<opts.allowedfileextensions.length;i++){ + if (files[fileIndex].name.substr(files[fileIndex].name.length-opts.allowedfileextensions[i].length).toLowerCase() + == opts.allowedfileextensions[i].toLowerCase() + ) { + allowedextension = true; + } + } + if (!allowedextension){ + opts.error(errors[8], files[fileIndex]); + return false; + } + } + } + + var filesDone = 0, + filesRejected = 0; + + if (files_count > opts.maxfiles && opts.queuefiles === 0) { + opts.error(errors[1]); + return false; + } + + // Define queues to manage upload process + var workQueue = []; + var processingQueue = []; + var doneQueue = []; + + // Add everything to the workQueue + for (var i = 0; i < files_count; i++) { + workQueue.push(i); + } + + // Helper function to enable pause of processing to wait + // for in process queue to complete + var pause = function(timeout) { + setTimeout(process, timeout); + return; + }; + + // Process an upload, recursive + var process = function() { + + var fileIndex; + + if (stop_loop) { + return false; + } + + // Check to see if are in queue mode + if (opts.queuefiles > 0 && processingQueue.length >= opts.queuefiles) { + return pause(opts.queuewait); + } else { + // Take first thing off work queue + fileIndex = workQueue[0]; + workQueue.splice(0, 1); + + // Add to processing queue + processingQueue.push(fileIndex); + } + + try { + if (beforeEach(files[fileIndex]) !== false) { + if (fileIndex === files_count) { + return; + } + var reader = new FileReader(), + max_file_size = 1048576 * opts.maxfilesize; + + reader.index = fileIndex; + if (files[fileIndex].size > max_file_size) { + opts.error(errors[2], files[fileIndex], fileIndex); + // Remove from queue + processingQueue.forEach(function(value, key) { + if (value === fileIndex) { + processingQueue.splice(key, 1); + } + }); + filesRejected++; + return true; + } + + reader.onerror = function(e) { + switch(e.target.error.code) { + case e.target.error.NOT_FOUND_ERR: + opts.error(errors[4]); + return false; + case e.target.error.NOT_READABLE_ERR: + opts.error(errors[5]); + return false; + case e.target.error.ABORT_ERR: + opts.error(errors[6]); + return false; + default: + opts.error(errors[7]); + return false; + }; + }; + + reader.onloadend = !opts.beforeSend ? send : function (e) { + opts.beforeSend(files[fileIndex], fileIndex, function () { send(e); }); + }; + + reader.readAsDataURL(files[fileIndex]); + + } else { + filesRejected++; + } + } catch (err) { + // Remove from queue + processingQueue.forEach(function(value, key) { + if (value === fileIndex) { + processingQueue.splice(key, 1); + } + }); + opts.error(errors[0]); + return false; + } + + // If we still have work to do, + if (workQueue.length > 0) { + process(); + } + }; + + var send = function(e) { + + var fileIndex = (e.srcElement || e.target).index; + + // Sometimes the index is not attached to the + // event object. Find it by size. Hack for sure. + if (e.target.index === undefined) { + e.target.index = getIndexBySize(e.total); + } + + var xhr = new XMLHttpRequest(), + upload = xhr.upload, + file = files[e.target.index], + index = e.target.index, + start_time = new Date().getTime(), + boundary = '------multipartformboundary' + (new Date()).getTime(), + global_progress_index = global_progress.length, + builder, + newName = rename(file.name), + mime = file.type; + + if (opts.withCredentials) { + xhr.withCredentials = opts.withCredentials; + } + + var data = atob(e.target.result.split(',')[1]); + if (typeof newName === "string") { + builder = getBuilder(newName, data, mime, boundary); + } else { + builder = getBuilder(file.name, data, mime, boundary); + } + + upload.index = index; + upload.file = file; + upload.downloadStartTime = start_time; + upload.currentStart = start_time; + upload.currentProgress = 0; + upload.global_progress_index = global_progress_index; + upload.startData = 0; + upload.addEventListener("progress", progress, false); + + // Allow url to be a method + if (jQuery.isFunction(opts.url)) { + xhr.open(opts.requestType, opts.url(), true); + } else { + xhr.open(opts.requestType, opts.url, true); + } + + xhr.setRequestHeader('content-type', 'multipart/form-data; boundary=' + boundary); + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + + // Add headers + $.each(opts.headers, function(k, v) { + xhr.setRequestHeader(k, v); + }); + + xhr.sendAsBinary(builder); + + global_progress[global_progress_index] = 0; + globalProgress(); + + opts.uploadStarted(index, file, files_count); + + xhr.onload = function() { + var serverResponse = null; + + if (xhr.responseText) { + try { + serverResponse = jQuery.parseJSON(xhr.responseText); + } + catch (e) { + serverResponse = xhr.responseText; + } + } + + var now = new Date().getTime(), + timeDiff = now - start_time, + result = opts.uploadFinished(index, file, serverResponse, timeDiff, xhr); + filesDone++; + + // Remove from processing queue + processingQueue.forEach(function(value, key) { + if (value === fileIndex) { + processingQueue.splice(key, 1); + } + }); + + // Add to donequeue + doneQueue.push(fileIndex); + + // Make sure the global progress is updated + global_progress[global_progress_index] = 100; + globalProgress(); + + if (filesDone === (files_count - filesRejected)) { + afterAll(); + } + if (result === false) { + stop_loop = true; + } + + + // Pass any errors to the error option + if (xhr.status < 200 || xhr.status > 299) { + opts.error(xhr.statusText, file, fileIndex, xhr.status); + } + }; + }; + + // Initiate the processing loop + process(); + } + + function getIndexBySize(size) { + for (var i = 0; i < files_count; i++) { + if (files[i].size === size) { + return i; + } + } + + return undefined; + } + + function rename(name) { + return opts.rename(name); + } + + function beforeEach(file) { + return opts.beforeEach(file); + } + + function afterAll() { + return opts.afterAll(); + } + + function dragEnter(e) { + clearTimeout(doc_leave_timer); + e.preventDefault(); + opts.dragEnter.call(this, e); + } + + function dragOver(e) { + clearTimeout(doc_leave_timer); + e.preventDefault(); + opts.docOver.call(this, e); + opts.dragOver.call(this, e); + } + + function dragLeave(e) { + clearTimeout(doc_leave_timer); + opts.dragLeave.call(this, e); + e.stopPropagation(); + } + + function docDrop(e) { + e.preventDefault(); + opts.docLeave.call(this, e); + return false; + } + + function docEnter(e) { + clearTimeout(doc_leave_timer); + e.preventDefault(); + opts.docEnter.call(this, e); + return false; + } + + function docOver(e) { + clearTimeout(doc_leave_timer); + e.preventDefault(); + opts.docOver.call(this, e); + return false; + } + + function docLeave(e) { + doc_leave_timer = setTimeout((function(_this) { + return function() { + opts.docLeave.call(_this, e); + }; + })(this), 200); + } + + return this; + }; + + function empty() {} + + try { + if (XMLHttpRequest.prototype.sendAsBinary) { + return; + } + XMLHttpRequest.prototype.sendAsBinary = function(datastr) { + function byteValue(x) { + return x.charCodeAt(0) & 0xff; + } + var ords = Array.prototype.map.call(datastr, byteValue); + var ui8a = new Uint8Array(ords); + + // Not pretty: Chrome 22 deprecated sending ArrayBuffer, moving instead + // to sending ArrayBufferView. Sadly, no proper way to detect this + // functionality has been discovered. Happily, Chrome 22 also introduced + // the base ArrayBufferView class, not present in Chrome 21. + if ('ArrayBufferView' in window) + this.send(ui8a); + else + this.send(ui8a.buffer); + }; + } catch (e) {} + +})(jQuery); diff --git a/scp/ajax.php b/scp/ajax.php index cdeec76d9bb1f7ef5b5e3ae3b86fb009b47e4fe9..7cfe289d568964f6fd8fb22e5b4f349460d793ab 100644 --- a/scp/ajax.php +++ b/scp/ajax.php @@ -55,7 +55,9 @@ $dispatcher = patterns('', url_get('^help-topic/(?P<id>\d+)$', 'getFormsForHelpTopic'), url_get('^field-config/(?P<id>\d+)$', 'getFieldConfiguration'), url_post('^field-config/(?P<id>\d+)$', 'saveFieldConfiguration'), - url_delete('^answer/(?P<entry>\d+)/(?P<field>\d+)$', 'deleteAnswer') + url_delete('^answer/(?P<entry>\d+)/(?P<field>\d+)$', 'deleteAnswer'), + url_post('^upload/(\d+)?$', 'upload'), + url_post('^upload/(\w+)?$', 'attach') )), url('^/list/', patterns('ajax.forms.php:DynamicFormsAjaxAPI', url_get('^(?P<list>\w+)/item/(?P<id>\d+)/properties$', 'getListItemProperties'),