1/**
2 * EGroupware eTemplate2 - JS Number object
3 *
4 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
5 * @package etemplate
6 * @subpackage api
7 * @link http://www.egroupware.org
8 * @author Nathan Gray
9 * @copyright Nathan Gray 2011
10 * @version $Id$
11 */
12
13/*egw:uses
14	et2_core_inputWidget;
15	phpgwapi.Resumable.resumable;
16*/
17
18import {et2_inputWidget} from "./et2_core_inputWidget";
19import {et2_register_widget, WidgetConfig} from "./et2_core_widget";
20import {ClassWithAttributes} from "./et2_core_inheritance";
21
22/**
23 * Class which implements file upload
24 *
25 * @augments et2_inputWidget
26 */
27export class et2_file extends et2_inputWidget
28{
29	static readonly _attributes : any = {
30		"multiple": {
31			"name": "Multiple files",
32			"type": "boolean",
33			"default": false,
34			"description": "Allow the user to select more than one file to upload at a time.  Subject to browser support."
35		},
36		"max_file_size": {
37			"name": "Maximum file size",
38			"type": "integer",
39			"default":0,
40			"description": "Largest file accepted, in bytes.  Subject to server limitations.  8MB = 8388608"
41		},
42		"mime": {
43			"name": "Allowed file types",
44			"type": "string",
45			"default": et2_no_init,
46			"description": "Mime type (eg: image/png) or regex (eg: /^text\//i) for allowed file types"
47		},
48		"blur": {
49			"name": "Placeholder",
50			"type": "string",
51			"default": "",
52			"description": "This text get displayed if an input-field is empty and does not have the input-focus (blur). It can be used to show a default value or a kind of help-text."
53		},
54		"progress": {
55			"name": "Progress node",
56			"type": "string",
57			"default": et2_no_init,
58			"description": "The ID of an alternate node (div) to display progress and results.  The Node is fetched with et2 getWidgetById so you MUST use the id assigned in XET-File (it may not be available at creation time, so we (re)check on createStatus time)"
59		},
60		"onStart": {
61			"name": "Start event handler",
62			"type": "any",
63			"default": et2_no_init,
64			"description": "A (js) function called when an upload starts.  Return true to continue with upload, false to cancel."
65		},
66		"onFinish": {
67			"name": "Finish event handler",
68			"type": "any",
69			"default": et2_no_init,
70			"description": "A (js) function called when all files to be uploaded are finished."
71		},
72		drop_target: {
73			"name": "Optional, additional drop target for HTML5 uploads",
74			"type": "string",
75			"default": et2_no_init,
76			"description": "The ID of an additional drop target for HTML5 drag-n-drop file uploads"
77		},
78		label: {
79			"name": "Label of file upload",
80			"type": "string",
81			"default": "Choose file...",
82			"description": "String caption to be displayed on file upload span"
83		},
84		progress_dropdownlist: {
85			"name": "List on files in progress like dropdown",
86			"type": "boolean",
87			"default": false,
88			"description": "Style list of files in uploading progress like dropdown list with a total upload progress indicator"
89		},
90		onFinishOne: {
91			"name": "Finish event handler for each one",
92			"type": "js",
93			"default": et2_no_init,
94			"description": "A (js) function called when a file to be uploaded is finished."
95		},
96		accept: {
97			"name": "Acceptable extensions",
98			"type": "string",
99			"default": '',
100			"description": "Define types of files that the server accepts. Multiple types can be seperated by comma and the default is to accept everything."
101		},
102		chunk_size: {
103			"name": "Chunk size",
104			"type": "integer",
105			"default": 1024*1024,
106			"description": "Max chunk size, gets set from server-side PHP (max_upload_size-1M)/2"	// last chunk can be up to 2*chunk_size!
107		}
108	};
109
110	asyncOptions : any = {};
111	input : JQuery = null;
112	progress : JQuery = null;
113	span : JQuery = null;
114	disabled_buttons : JQuery;
115	resumable : any;
116
117	/**
118	 * Constructor
119	 *
120	 * @memberOf et2_file
121	 */
122	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
123	{
124		// Call the inherited constructor
125		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_file._attributes, _child || {}));
126
127		this.node = null;
128		this.input = null;
129		this.progress = null;
130		this.span = null;
131		// Contains all submit buttons need to be disabled during upload process
132		this.disabled_buttons = jQuery("input[type='submit'], button");
133
134		// Make sure it's an object, not an array, or values get lost when sent to server
135		this.options.value = jQuery.extend({},this.options.value);
136
137		if(!this.options.id) {
138			console.warn("File widget needs an ID.  Used 'file_widget'.");
139			this.options.id = "file_widget";
140		}
141
142		// Legacy - id ending in [] means multiple
143		if(this.options.id.substr(-2) == "[]")
144		{
145			this.options.multiple = true;
146		}
147		// If ID ends in /, it's a directory - allow multiple
148		else if (this.options.id.substr(-1) === "/")
149		{
150			this.options.multiple = true;
151			_attrs.multiple = true;
152		}
153
154		// Set up the URL to have the request ID & the widget ID
155		var instance = this.getInstanceManager();
156
157		let self = this;
158
159		this.asyncOptions = jQuery.extend({
160
161		},this.getAsyncOptions(this));
162		this.asyncOptions.fieldName = this.options.id;
163		this.createInputWidget();
164		this.set_readonly(this.options.readonly);
165	}
166
167	destroy()
168	{
169		super.destroy();
170		this.set_drop_target(null);
171		this.node = null;
172		this.input = null;
173		this.span = null;
174		this.progress = null;
175	}
176
177	createInputWidget()
178	{
179		this.node = <HTMLElement><unknown>jQuery(document.createElement("div")).addClass("et2_file");
180		this.span = jQuery(document.createElement("span"))
181			.addClass('et2_file_span et2_button')
182			.appendTo (this.node);
183		if (this.options.label != '') this.span.addClass('et2_button_text');
184		let span = this.span;
185		this.input = jQuery(document.createElement("input"))
186			.attr("type", "file").attr("placeholder", this.options.blur)
187			.addClass ("et2_file_upload")
188			.appendTo(this.node)
189			.hover(function() {
190				jQuery(span)
191					.toggleClass('et2_file_spanHover');
192			})
193			.on({
194				mousedown:function (){
195					jQuery(span).addClass('et2_file_spanActive');
196				},
197				mouseup:function (){
198					jQuery(span).removeClass('et2_file_spanActive');
199				}
200			});
201		if (this.options.accept) this.input.attr('accept', this.options.accept);
202		let self = this;
203		// trigger native input upload file
204		if (!this.options.readonly) this.span.click(function(){self.input.click()});
205		// Check for File interface, should fall back to normal form submit if missing
206		if(typeof File != "undefined" && typeof (new XMLHttpRequest()).upload != "undefined")
207		{
208			this.resumable = new Resumable(this.asyncOptions);
209			this.resumable.assignBrowse(this.input);
210			this.resumable.on('fileAdded', jQuery.proxy(this._fileAdded, this));
211			this.resumable.on('fileProgress', jQuery.proxy(this._fileProgress, this));
212			this.resumable.on('fileSuccess', jQuery.proxy(this.finishUpload, this));
213			this.resumable.on('complete', jQuery.proxy(this.onFinish, this));
214		}
215		else
216		{
217			// This may be a problem submitting via ajax
218		}
219		if(this.options.progress)
220		{
221			let widget = this.getRoot().getWidgetById(this.options.progress);
222			if(widget)
223			{
224				//may be not available at createInputWidget time
225				this.progress = jQuery(widget.getDOMNode());
226			}
227		}
228		if(!this.progress)
229		{
230			this.progress = jQuery(document.createElement("div")).appendTo(this.node);
231		}
232		this.progress.addClass("progress");
233
234		if(this.options.multiple)
235		{
236			this.input.attr("multiple","multiple");
237		}
238
239		this.setDOMNode(this.node[0]);
240		// set drop target to widget dom node if no target option is specified
241		if (!this.options.drop_target) this.resumable.assignDrop([this.getDOMNode()]);
242	}
243
244	/**
245	 * Get any specific async upload options
246	 */
247	getAsyncOptions(self: et2_file)
248	{
249		return {
250			// Callbacks
251			onStart: function(event, file_count) {
252				return self.onStart(event, file_count);
253			},
254			onFinish: function(event, file_count) {
255				self.onFinish.apply(self, [event, file_count])
256			},
257			onStartOne: function(event, file_name, index, file_count) {
258
259			},
260			onFinishOne: function(event, response, name, number, total) { return self.finishUpload(event,response,name,number,total);},
261			onProgress: function(event, progress, name, number, total) { return self.onProgress(event,progress,name,number,total);},
262			onError: function(event, name, error) { return self.onError(event,name,error);},
263			beforeSend: function(form) { return self.beforeSend(form);},
264
265			chunkSize: this.options.chunk_size || 1024*1024,
266
267			target: egw.ajaxUrl("EGroupware\\Api\\Etemplate\\Widget\\File::ajax_upload"),
268			query: function(file) {return self.beforeSend(file);},
269			// Disable checking for already uploaded chunks
270			testChunks: false
271		};
272	}
273	/**
274	 * Set a widget or DOM node as a HTML5 file drop target
275	 *
276	 * @param {string} new_target widget ID or DOM node ID to be used as a new target
277	 */
278	set_drop_target(new_target : string)
279	{
280		// Cancel old drop target
281		if(this.options.drop_target)
282		{
283			let widget = this.getRoot().getWidgetById(this.options.drop_target);
284			let drop_target = widget && widget.getDOMNode() || document.getElementById(this.options.drop_target);
285			if(drop_target)
286			{
287				this.resumable.unAssignDrop(drop_target);
288			}
289		}
290
291		this.options.drop_target = new_target;
292
293		if(!this.options.drop_target) return;
294
295		// Set up new drop target
296		let widget = this.getRoot().getWidgetById(this.options.drop_target);
297		let drop_target = widget && widget.getDOMNode() || document.getElementById(this.options.drop_target);
298		if(drop_target)
299		{
300			this.resumable.assignDrop([drop_target]);
301		}
302		else
303		{
304			this.egw().debug("warn", "Did not find file drop target %s", this.options.drop_target);
305		}
306
307	}
308
309	attachToDOM()
310	{
311		let res = super.attachToDOM();
312		// Override parent's change, file widget will fire change when finished uploading
313		this.input.unbind("change.et2_inputWidget");
314		return res;
315	}
316
317	getValue()
318	{
319		return this.options.value ? this.options.value : this.input.val();
320	}
321
322	/**
323	 * Set the value of the file widget.
324	 *
325	 * If you pass a FileList or list of files, it will trigger the async upload
326	 *
327	 * @param {FileList|File[]|false} value List of files to be uploaded, or false to reset.
328	 * @param {Event} event Most browsers require the user to initiate file transfers in some way.
329	 *	Pass the event in, if you have it.
330	 */
331	set_value(value, event?) : boolean
332	{
333		if (!value || typeof value == "undefined") {
334			value = {};
335		}
336		if (jQuery.isEmptyObject(value)) {
337			this.options.value = {};
338			if (this.resumable.progress() == 1) this.progress.empty();
339
340			// Reset the HTML element
341			this.input.wrap('<form>').closest('form').get(0).reset();
342			this.input.unwrap();
343
344			return;
345		}
346
347		let addFile = jQuery.proxy(function (i, file) {
348			this.resumable.addFile(file, event);
349		}, this);
350		if (typeof value == 'object' && value.length && typeof value[0] == 'object' && value[0].name) {
351			try {
352				this.input[0].files = value;
353
354				jQuery.each(value, addFile);
355			} catch (e) {
356				var self = this;
357				var args = arguments;
358				jQuery.each(value, addFile);
359			}
360		}
361	}
362
363	/**
364	 * Set the value for label
365	 * The label is used as caption for span tag which customize the HTML file upload styling
366	 *
367	 * @param {string} value text value of label
368	 */
369	set_label(value)
370	{
371		if (this.span != null && value != null)
372		{
373			this.span.text(value);
374		}
375	}
376
377	getInputNode()
378	{
379		if (typeof this.input == 'undefined') return <HTMLElement><unknown>false;
380		return this.input[0];
381	}
382
383
384	set_mime(mime)
385	{
386		if(!mime)
387		{
388			this.options.mime = null;
389		}
390		if(mime.indexOf("/") != 0)
391		{
392			// Lower case it now, if it's not a regex
393			this.options.mime = mime.toLowerCase();
394		}
395		else
396		{
397			this.options.mime = mime;
398		}
399	}
400
401	set_multiple(_multiple)
402	{
403		this.options.multiple = _multiple;
404		if(_multiple)
405		{
406			return this.input.attr("multiple", "multiple");
407		}
408		return this.input.removeAttr("multiple");
409	}
410
411	/**
412	 * Check to see if the provided file's mimetype matches
413	 *
414	 * @param f File object
415	 * @return boolean
416	 */
417	checkMime(f)
418	{
419		if(!this.options.mime) return true;
420
421		let mime: string | RegExp = '';
422		if(this.options.mime.indexOf("/") != 0)
423		{
424			// Lower case it now, if it's not a regex
425			mime = this.options.mime.toLowerCase();
426		}
427		else
428		{
429			// Convert into a js regex
430			var parts = this.options.mime.substr(1).match(/(.*)\/([igm]?)$/);
431			mime = new RegExp(parts[1],parts.length > 2 ? parts[2] : "");
432		}
433
434		// If missing, let the server handle it
435		if(!mime || !f.type) return true;
436
437		var is_preg = (typeof mime == "object");
438		if(!is_preg && f.type.toLowerCase() == mime || is_preg && mime.test(f.type))
439		{
440			return true;
441		}
442
443		// Not right mime
444		return false;
445	}
446
447	private _fileAdded(file,event)
448	{
449		// Manual additions have no event
450		if(typeof event == 'undefined')
451		{
452			event = {};
453		}
454		// Trigger start of uploading, calls callback
455		if(!this.resumable.isUploading())
456		{
457			if (!(this.onStart(event,this.resumable.files.length))) return;
458		}
459
460		// Here 'this' is the input
461		if(this.checkMime(file.file))
462		{
463			if(this.createStatus(event,file))
464			{
465
466				// Disable buttons
467				this.disabled_buttons
468					.not("[disabled]")
469					.attr("disabled", true)
470					.addClass('et2_button_ro')
471					.removeClass('et2_clickable')
472					.css('cursor', 'default');
473
474				// Actually start uploading
475				this.resumable.upload();
476			}
477		}
478		else
479		{
480			// Wrong mime type - show in the list of files
481			return this.createStatus(
482				this.egw().lang("File is of wrong type (%1 != %2)!", file.file.type, this.options.mime),
483				file
484			);
485		}
486
487	}
488
489	/**
490	 * Add in the request id
491	 */
492	beforeSend(form) {
493		var instance = this.getInstanceManager();
494
495		return {
496			request_id: instance.etemplate_exec_id,
497			widget_id: this.id
498		};
499	}
500
501	/**
502	 * Disables submit buttons while uploading
503	 */
504	onStart(event, file_count) {
505		// Hide any previous errors
506		this.hideMessage();
507
508
509		event.data = this;
510
511		//Add dropdown_progress
512		if (this.options.progress_dropdownlist)
513		{
514			this._build_progressDropDownList();
515		}
516
517		// Callback
518		if(this.options.onStart) return et2_call(this.options.onStart, event, file_count);
519		return true;
520	}
521
522	/**
523	 * Re-enables submit buttons when done
524	 */
525	onFinish() {
526		this.disabled_buttons.removeAttr("disabled").css('cursor','pointer').removeClass('et2_button_ro');
527
528		var file_count = this.resumable.files.length;
529
530		// Remove files from list
531		while(this.resumable.files.length > 0)
532		{
533			this.resumable.removeFile(this.resumable.files[this.resumable.files.length -1]);
534		}
535
536		var event = jQuery.Event('upload');
537
538		event.data = this;
539
540		var result = false;
541
542		//Remove progress_dropDown_fileList class and unbind the click handler from body
543		if (this.options.progress_dropdownlist)
544		{
545			this.progress.removeClass("progress_dropDown_fileList");
546			jQuery(this.node).find('span').removeClass('totalProgress_loader');
547			jQuery('body').off('click');
548		}
549
550		if(this.options.onFinish && !jQuery.isEmptyObject(this.getValue()))
551		{
552			result =  et2_call(this.options.onFinish, event, file_count);
553		}
554		else
555		{
556			result = (file_count == 0 || !jQuery.isEmptyObject(this.getValue()));
557		}
558		if(result)
559		{
560			// Fire legacy change action when done
561			this.change(this.input);
562		}
563	}
564
565	/**
566	 * Build up dropdown progress with total count indicator
567	 *
568	 * @todo Implement totalProgress bar instead of ajax-loader, in order to show how much percent of uploading is completed
569	 */
570	private _build_progressDropDownList()
571	{
572		this.progress.addClass("progress_dropDown_fileList");
573
574		//Add uploading indicator and bind hover handler on it
575		jQuery(this.node).find('span').addClass('totalProgress_loader');
576
577		jQuery(this.node).find('span.et2_file_span').hover(function(){
578			jQuery('.progress_dropDown_fileList').show();
579		});
580		//Bind click handler to dismiss the dropdown while uploading
581		jQuery('body').on('click', function(event){
582			if (event.target.className != 'remove')
583			{
584				jQuery('.progress_dropDown_fileList').hide();
585			}
586		});
587
588	}
589
590	/**
591	 * Creates the elements used for displaying the file, and it's upload status, and
592	 * attaches them to the DOM
593	 *
594	 * @param _event Either the event, or an error message
595	 */
596	createStatus(_event, file)
597	{
598		var error = (typeof _event == "object" ? "" : _event);
599
600		if(this.options.max_file_size && file.size > this.options.max_file_size) {
601			error = this.egw().lang("File too large.  Maximum %1", et2_vfsSize.prototype.human_size(this.options.max_file_size));
602		}
603
604		if(this.options.progress)
605		{
606			var widget = this.getRoot().getWidgetById(this.options.progress);
607			if(widget)
608			{
609				this.progress = jQuery(widget.getDOMNode());
610				this.progress.addClass("progress");
611			}
612		}
613		if(this.progress)
614		{
615			var fileName = file.fileName || 'file';
616			var status = jQuery("<li data-file='"+fileName.replace(/'/g, '&quot')+"'>"+fileName
617				+"<div class='remove'/><span class='progressBar'><p/></span></li>")
618				.appendTo(this.progress);
619			jQuery("div.remove",status).on('click', file, jQuery.proxy(this.cancel,this));
620			if(error != "")
621			{
622				status.addClass("message ui-state-error");
623				status.append("<div>"+error+"</diff>");
624				jQuery(".progressBar",status).css("display", "none");
625			}
626		}
627		return error == "";
628	}
629
630	private _fileProgress(file)
631	{
632		if(this.progress)
633		{
634			jQuery("li[data-file='"+file.fileName.replace(/'/g, '&quot')+"'] > span.progressBar > p").css("width", Math.ceil(file.progress()*100)+"%");
635
636		}
637		return true;
638	}
639
640	onError(event, name, error)
641	{
642		console.warn(event,name,error);
643	}
644
645	/**
646	 * A file upload is finished, update the UI
647	 */
648	finishUpload(file, response)
649	{
650		var name = file.fileName || 'file';
651
652		if(typeof response == 'string') response = jQuery.parseJSON(response);
653		if(response.response[0] && typeof response.response[0].data.length == 'undefined') {
654			if(typeof this.options.value !== 'object' || !this.options.multiple)
655			{
656				this.set_value({});
657			}
658			for(var key in response.response[0].data) {
659				if(typeof response.response[0].data[key] == "string")
660				{
661					// Message from server - probably error
662					jQuery("[data-file='"+name.replace(/'/g, '&quot')+"']",this.progress)
663						.addClass("error")
664						.css("display", "block")
665						.text(response.response[0].data[key]);
666				}
667				else
668				{
669					this.options.value[key] = response.response[0].data[key];
670					// If not multiple, we already destroyed the status, so re-create it
671					if(!this.options.multiple)
672					{
673						this.createStatus({}, file);
674					}
675					if(this.progress)
676					{
677						jQuery("[data-file='"+name.replace(/'/g, '&quot')+"']",this.progress).addClass("message success");
678					}
679				}
680			}
681		}
682		else if (this.progress)
683		{
684			jQuery("[data-file='"+name.replace(/'/g, '&quot')+"']",this.progress)
685				.addClass("ui-state-error")
686				.css("display", "block")
687				.text(this.egw().lang("Server error"));
688		}
689		var event = jQuery.Event('upload');
690
691		event.data = this;
692
693		// Callback
694		if(typeof this.onFinishOne == 'function')
695		{
696			this.onFinishOne(event, response, name);
697		}
698		return true;
699	}
700
701	/**
702	 * Remove a file from the list of values
703	 *
704	 * @param {File|string} File object, or file name, to remove
705	 */
706	remove_file(file)
707	{
708		//console.info(filename);
709		if(typeof file == 'string')
710		{
711			file = {fileName: file};
712		}
713		for(var key in this.options.value)
714		{
715			if(this.options.value[key].name == file.fileName)
716			{
717				delete this.options.value[key];
718				jQuery('[data-file="'+file.fileName.replace(/'/g, '&quot')+'"]',this.node).remove();
719				return;
720			}
721		}
722		if(file.isComplete && !file.isComplete() && file.cancel) file.cancel();
723	}
724
725	/**
726	 * Cancel a file - event callback
727	 */
728	cancel(e)
729	{
730		e.preventDefault();
731		// Look for file name in list
732		var target = jQuery(e.target).parents("li");
733
734		this.remove_file(e.data);
735
736		// In case it didn't make it to the list (error)
737		target.remove();
738		jQuery(e.target).remove();
739	}
740
741	/**
742	 * Set readonly
743	 *
744	 * @param {boolean} _ro boolean readonly state, true means readonly
745	 */
746	set_readonly(_ro)
747	{
748		if (typeof _ro != "undefined")
749		{
750			this.options.readonly = _ro;
751			this.span.toggleClass('et2_file_ro',_ro);
752			if (this.options.readonly)
753			{
754				this.span.unbind('click');
755			}
756			else
757			{
758				var self = this;
759				this.span.off().bind('click',function(){self.input.click()});
760			}
761		}
762	}
763}
764et2_register_widget(et2_file, ["file"]);
765
766
767