1/** 2 * EGroupware - Filemanager - Javascript UI 3 * 4 * @link http://www.egroupware.org 5 * @package filemanager 6 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de> 7 * @copyright (c) 2008-19 by Ralf Becker <RalfBecker-AT-outdoor-training.de> 8 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License 9 */ 10 11/*egw:uses 12 /api/js/jsapi/egw_app.js; 13 */ 14import {EgwApp} from "../../api/js/jsapi/egw_app"; 15import {et2_nextmatch} from "../../api/js/etemplate/et2_extension_nextmatch"; 16 17/** 18 * UI for filemanager 19 */ 20export class filemanagerAPP extends EgwApp 21{ 22 /** 23 * path widget, by template 24 */ 25 path_widget: {} = {}; 26 /** 27 * Are files cut into clipboard - need to be deleted at source on paste 28 */ 29 clipboard_is_cut: boolean = false; 30 31 /** 32 * Regexp to convert id to a path, use this.id2path(_id) 33 */ 34 private remove_prefix : RegExp = /^filemanager::/; 35 36 private readonly; 37 38 /** 39 * Constructor 40 * 41 * @memberOf app.filemanager 42 */ 43 constructor() 44 { 45 // call parent 46 super('filemanager'); 47 48 // Loading filemanager in its tab and home causes us problems with 49 // unwanted destruction, so we check for already existing path widgets 50 let lists = etemplate2.getByApplication('home'); 51 for (let i = 0; i < lists.length; i++) 52 { 53 if(lists[i].app == 'filemanager' && lists[i].widgetContainer.getWidgetById('path')) 54 { 55 this.path_widget[lists[i].uniqueId] = lists[i].widgetContainer.getWidgetById('path'); 56 } 57 } 58 } 59 60 /** 61 * Destructor 62 */ 63 destroy(_app) 64 { 65 delete this.et2; 66 67 // call parent 68 super.destroy(_app) 69 } 70 71 /** 72 * This function is called when the etemplate2 object is loaded 73 * and ready. If you must store a reference to the et2 object, 74 * make sure to clean it up in destroy(). 75 * 76 * @param et2 etemplate2 Newly ready object 77 * @param {string} name template name 78 */ 79 et2_ready(et2,name) 80 { 81 // call parent 82 super.et2_ready(et2, name); 83 84 let path_widget = this.et2.getWidgetById('path'); 85 if(path_widget) // do NOT set not found path-widgets, as uploads works on first one only! 86 { 87 this.path_widget[et2.DOMContainer.id] = path_widget; 88 // Bind to removal to remove from list 89 jQuery(et2.DOMContainer).on('clear', function(e) { 90 if (app.filemanager && app.filemanager.path_widget) delete app.filemanager.path_widget[e.target.id]; 91 }); 92 } 93 94 if(this.et2.getWidgetById('nm')) 95 { 96 // Legacy JS only supports 2 arguments (event and widget), so set 97 // to the actual function here 98 this.et2.getWidgetById('nm').set_onfiledrop(jQuery.proxy(this.filedrop, this)); 99 } 100 101 // get clipboard from browser localstore and update button tooltips 102 this.clipboard_tooltips(); 103 104 // calling set_readonly for initial path 105 if (this.et2.getArrayMgr('content').getEntry('initial_path_readonly')) 106 { 107 this.readonly = [this.et2.getArrayMgr('content').getEntry('nm[path]'), true]; 108 } 109 if (typeof this.readonly != 'undefined') 110 { 111 this.set_readonly.apply(this, this.readonly); 112 delete this.readonly; 113 } 114 115 if (name == 'filemanager.index') 116 { 117 let fe = egw.link_get_registry('filemanager-editor'); 118 let new_widget = this.et2.getWidgetById('new'); 119 if (fe && fe["edit"]) 120 { 121 let new_options = this.et2.getArrayMgr('sel_options').getEntry('new'); 122 new_widget.set_select_options(new_options); 123 } 124 else if (new_widget) 125 { 126 new_widget.set_disabled(true); 127 } 128 } 129 } 130 131 /** 132 * Set the application's state to the given state. 133 * 134 * Extended from parent to also handle view 135 * 136 * 137 * @param {{name: string, state: object}|string} state Object (or JSON string) for a state. 138 * Only state is required, and its contents are application specific. 139 * 140 * @return {boolean} false - Returns false to stop event propagation 141 */ 142 setState(state) 143 { 144 // State should be an object, not a string, but we'll parse 145 if(typeof state == "string") 146 { 147 if(state.indexOf('{') != -1 || state =='null') 148 { 149 state = JSON.parse(state); 150 } 151 } 152 let result = super.setState(state, 'filemanager.index'); 153 154 // This has to happen after the parent, changing to tile recreates 155 // nm controller 156 if(typeof state == "object" && state.state && state.state.view) 157 { 158 let et2 = etemplate2.getById('filemanager-index'); 159 if(et2) 160 { 161 this.et2 = et2.widgetContainer; 162 this.change_view(state.state.view); 163 } 164 } 165 return result; 166 } 167 168 /** 169 * Retrieve the current state of the application for future restoration 170 * 171 * Extended from parent to also set view 172 * 173 * @return {object} Application specific map representing the current state 174 */ 175 getState() 176 { 177 let state = super.getState(); 178 179 let et2 = etemplate2.getById('filemanager-index'); 180 if(et2) 181 { 182 let nm = et2.widgetContainer.getWidgetById('nm'); 183 state.view = nm.view; 184 } 185 return state; 186 } 187 188 /** 189 * Convert id to path (remove "filemanager::" prefix) 190 */ 191 id2path(_id : string) : string 192 { 193 return _id.replace(this.remove_prefix, ''); 194 } 195 196 /** 197 * Convert array of elems to array of paths 198 */ 199 _elems2paths(_elems) : string[] 200 { 201 let paths = []; 202 for (let i = 0; i < _elems.length; i++) 203 { 204 // If selected has no id, try parent. This happens for the placeholder row 205 // in empty directories. 206 paths.push(_elems[i].id? this.id2path(_elems[i].id) : _elems[i]._context._parentId); 207 } 208 return paths; 209 } 210 211 /** 212 * Get directory of a path 213 */ 214 dirname(_path : string) : string 215 { 216 let parts = _path.split('/'); 217 parts.pop(); 218 return parts.join('/') || '/'; 219 } 220 221 /** 222 * Get name of a path 223 */ 224 basename(_path : string) : string 225 { 226 return _path.split('/').pop(); 227 } 228 229 /** 230 * Get current working directory 231 */ 232 get_path(etemplate_name? : string) : string 233 { 234 if(!etemplate_name || typeof this.path_widget[etemplate_name] == 'undefined') 235 { 236 for(etemplate_name in this.path_widget) break; 237 } 238 let path_widget = this.path_widget[etemplate_name]; 239 return path_widget ? path_widget.get_value.apply(path_widget) : null; 240 } 241 242 /** 243 * Open compose with already attached files 244 * 245 * @param {(string|string[])} attachments path(s) 246 * @param {object} params 247 */ 248 open_mail(attachments : string | string[], params? : object) 249 { 250 if (typeof attachments == 'undefined') attachments = this.get_clipboard_files(); 251 if (!params || typeof params != 'object') params = {}; 252 if (!(attachments instanceof Array)) attachments = [ attachments ]; 253 let content = {data:{files:{file:[]}}}; 254 for(let i=0; i < attachments.length; i++) 255 { 256 params['preset[file]['+i+']'] = 'vfs://default'+attachments[i]; 257 content.data.files.file.push('vfs://default'+attachments[i]); 258 } 259 content.data.files["filemode"] = params['preset[filemode]']; 260 // always open compose in html mode, as attachment links look a lot nicer in html 261 params["mimeType"] = 'html'; 262 return egw.openWithinWindow("mail", "setCompose", content, params, /mail.mail_compose.compose/); 263 } 264 265 /** 266 * Mail files action: open compose with already attached files 267 * 268 * @param _action 269 * @param _elems 270 */ 271 mail(_action, _elems) 272 { 273 this.open_mail(this._elems2paths(_elems), { 274 'preset[filemode]': _action.id.substr(5) 275 }); 276 } 277 278 /** 279 * Mail files action: open compose with already linked files 280 * We're only interested in hidden upload shares here, open_mail can handle 281 * the rest 282 * 283 * @param {egwAction} _action 284 * @param {egwActionObject[]} _selected 285 */ 286 mail_share_link(_action, _selected) 287 { 288 if(_action.id !== 'mail_shareUploadDir') 289 { 290 return this.mail(_action, _selected); 291 } 292 let path = this.id2path(_selected[0].id); 293 294 this.share_link(_action, _selected, null, false, false, this._mail_link_callback); 295 296 return true; 297 } 298 299 /** 300 * Callback with the share link to append to an email 301 * 302 * @param {Object} _data 303 * @param {String} _data.share_link Link to the share 304 * @param {String} _data.title Title for the link 305 * @param {String} [_data.msg] Error message 306 */ 307 _mail_link_callback(_data) 308 { 309 debugger; 310 if (_data.msg || !_data.share_link) window.egw_refresh(_data.msg, this.appname); 311 312 let params = { 313 'preset[body]': '<a href="'+_data.share_link + '">'+_data.title+'</a>', 314 'mimeType': 'html'// always open compose in html mode, as attachment links look a lot nicer in html 315 }; 316 let content = { 317 mail_htmltext: ['<br /><a href="'+_data.share_link + '">'+_data.title+'</a>'], 318 mail_plaintext: ["\n"+_data.share_link] 319 }; 320 return egw.openWithinWindow("mail", "setCompose", content, params, /mail.mail_compose.compose/); 321 } 322 323 /** 324 * Trigger Upload after each file is uploaded 325 * @param {type} _event 326 */ 327 uploadOnOne(_event) 328 { 329 this.upload(_event,1); 330 } 331 332 /** 333 * Send names of uploaded files (again) to server, to process them: either copy to vfs or ask overwrite/rename 334 * 335 * @param {event} _event 336 * @param {number} _file_count 337 * @param {string=} _path where the file is uploaded to, default current directory 338 * @param {string} _conflict What to do if the file conflicts with one on the server 339 * @param {string} _target Upload processing target. Sharing classes can override this. 340 */ 341 upload(_event, _file_count : number, _path? : string, _conflict = "ask", _target: string = 'filemanager_ui::ajax_action') 342 { 343 if(typeof _path == 'undefined') 344 { 345 _path = this.get_path(); 346 } 347 if (_file_count && !jQuery.isEmptyObject(_event.data.getValue())) 348 { 349 let widget = _event.data; 350 let value = widget.getValue(); 351 value.conflict = _conflict; 352 egw.json(_target, ['upload', value, _path], 353 this._upload_callback, this, true, this 354 ).sendRequest(); 355 widget.set_value(''); 356 } 357 } 358 359 /** 360 * Finish callback for file a file dialog, to get the overwrite / rename prompt 361 * 362 * @param {event} _event 363 * @param {number} _file_count 364 */ 365 file_a_file_upload(_event, _file_count : number) : boolean 366 { 367 let widget = _event.data; 368 let path = widget.getRoot().getWidgetById("path").getValue(); 369 let action = widget.getRoot().getWidgetById("action").getValue(); 370 let link = widget.getRoot().getWidgetById("entry").getValue(); 371 if(action == 'save_as' && link.app && link.id) 372 { 373 path = "/apps/"+link.app+"/"+link.id; 374 } 375 376 let props = widget.getInstanceManager().getValues(widget.getRoot()); 377 egw.json('filemanager_ui::ajax_action', [action == 'save_as' ? 'upload' : 'link', widget.getValue(), path, props], 378 function(_data) 379 { 380 app.filemanager._upload_callback(_data); 381 382 // Remove successful after a delay 383 for(var file in _data.uploaded) 384 { 385 if(!_data.uploaded[file].confirm || _data.uploaded[file].confirmed) 386 { 387 // Remove that file from file widget... 388 widget.remove_file(_data.uploaded[file].name); 389 } 390 } 391 opener.egw_refresh('','filemanager',null,null,'filemanager'); 392 }, app.filemanager, true, this 393 ).sendRequest(true); 394 return true; 395 } 396 397 398 /** 399 * Callback for server response to upload request: 400 * - display message and refresh list 401 * - ask use to confirm overwritting existing files or rename upload 402 * 403 * @param {object} _data values for attributes msg, files, ... 404 */ 405 _upload_callback(_data) 406 { 407 if(_data.msg || _data.uploaded) window.egw_refresh(_data.msg, this.appname, undefined, undefined, undefined, undefined, undefined, _data.type); 408 409 let that = this; 410 for (let file in _data.uploaded) 411 { 412 if(_data.uploaded[file].confirm && !_data.uploaded[file].confirmed) 413 { 414 let buttons = [ 415 { 416 text: this.egw.lang("Yes"), 417 id: "overwrite", 418 class: "ui-priority-primary", 419 "default": true, 420 image: 'check' 421 }, 422 {text: this.egw.lang("Rename"), id: "rename", image: 'edit'}, 423 {text: this.egw.lang("Cancel"), id: "cancel"} 424 ]; 425 if (_data.uploaded[file].confirm === "is_dir") 426 buttons.shift(); 427 let dialog = et2_dialog.show_prompt(function(_button_id, _value) { 428 let uploaded = {}; 429 uploaded[this.my_data.file] = this.my_data.data; 430 switch (_button_id) 431 { 432 case "overwrite": 433 uploaded[this.my_data.file].confirmed = true; 434 // fall through 435 case "rename": 436 uploaded[this.my_data.file].name = _value; 437 delete uploaded[this.my_data.file].confirm; 438 // send overwrite-confirmation and/or rename request to server 439 egw.json('filemanager_ui::ajax_action', [this.my_data.action, uploaded, this.my_data.path, this.my_data.props], 440 that._upload_callback, that, true, that 441 ).sendRequest(); 442 return; 443 case "cancel": 444 // Remove that file from every file widget... 445 that.et2.iterateOver(function(_widget) { 446 _widget.remove_file(this.my_data.data.name); 447 }, this, et2_file); 448 } 449 }, 450 _data.uploaded[file].confirm === "is_dir" ? 451 this.egw.lang("There's already a directory with that name!") : 452 this.egw.lang('Do you want to overwrite existing file %1 in directory %2?', _data.uploaded[file].name, _data.path), 453 this.egw.lang('File %1 already exists', _data.uploaded[file].name), 454 _data.uploaded[file].name, buttons, file); 455 // setting required data for callback in as my_data 456 dialog.my_data = { 457 action: _data.action, 458 file: file, 459 path: _data.path, 460 data: _data.uploaded[file], 461 props: _data.props 462 }; 463 } 464 } 465 } 466 467 /** 468 * Get any files that are in the system clipboard 469 * 470 * @return {string[]} Paths 471 */ 472 get_clipboard_files() 473 { 474 let clipboard_files = []; 475 if (typeof window.localStorage != 'undefined' && typeof egw.getSessionItem('phpgwapi', 'egw_clipboard') != 'undefined') 476 { 477 let clipboard = JSON.parse(egw.getSessionItem('phpgwapi', 'egw_clipboard')) || { 478 type:[], 479 selected:[] 480 }; 481 if(clipboard.type.indexOf('file') >= 0) 482 { 483 for(let i = 0; i < clipboard.selected.length; i++) 484 { 485 let split = clipboard.selected[i].id.split('::'); 486 if(split[0] == 'filemanager') 487 { 488 clipboard_files.push(this.id2path(clipboard.selected[i].id)); 489 } 490 } 491 } 492 } 493 return clipboard_files; 494 } 495 496 /** 497 * Update clickboard tooltips in buttons 498 */ 499 clipboard_tooltips() 500 { 501 let paste_buttons = ['button[paste]', 'button[linkpaste]', 'button[mailpaste]']; 502 for(let i=0; i < paste_buttons.length; ++i) 503 { 504 let button = this.et2.getWidgetById(paste_buttons[i]); 505 if (button) button.set_statustext(this.get_clipboard_files().join(",\n")); 506 } 507 } 508 509 /** 510 * Clip files into clipboard 511 * 512 * @param _action 513 * @param _elems 514 */ 515 clipboard(_action, _elems) 516 { 517 this.clipboard_is_cut = _action.id == "cut"; 518 let clipboard = JSON.parse(egw.getSessionItem('phpgwapi', 'egw_clipboard')) || { 519 type:[], 520 selected:[] 521 }; 522 if(_action.id != "add") 523 { 524 clipboard = { 525 type:[], 526 selected:[] 527 }; 528 } 529 530 // When pasting we need to know the type of data - pull from actions 531 let drag = _elems[0].getSelectedLinks('drag').links; 532 for(let k in drag) 533 { 534 if(drag[k].enabled && drag[k].actionObj.dragType.length > 0) 535 { 536 clipboard.type = clipboard.type.concat(drag[k].actionObj.dragType); 537 } 538 } 539 clipboard.type = jQuery.unique(clipboard.type); 540 // egwAction is a circular structure and can't be stringified so just take what we want 541 // Hopefully that's enough for the action handlers 542 for(let k in _elems) 543 { 544 if(_elems[k].id) clipboard.selected.push({id:_elems[k].id, data:_elems[k].data}); 545 } 546 547 // Save it in session 548 egw.setSessionItem('phpgwapi', 'egw_clipboard', JSON.stringify(clipboard)); 549 550 this.clipboard_tooltips(); 551 } 552 553 /** 554 * Paste files into current directory or mail them 555 * 556 * @param _type 'paste', 'linkpaste', 'mailpaste' 557 */ 558 paste(_type : string) 559 { 560 let clipboard_files = this.get_clipboard_files(); 561 if (clipboard_files.length == 0) 562 { 563 alert(this.egw.lang('Clipboard is empty!')); 564 return; 565 } 566 switch(_type) 567 { 568 case 'mailpaste': 569 this.open_mail(clipboard_files); 570 break; 571 572 case 'paste': 573 this._do_action(this.clipboard_is_cut ? 'move' : 'copy', clipboard_files); 574 575 if (this.clipboard_is_cut) 576 { 577 this.clipboard_is_cut = false; 578 clipboard_files = []; 579 this.clipboard_tooltips(); 580 } 581 break; 582 583 case 'linkpaste': 584 this._do_action('symlink', clipboard_files); 585 break; 586 } 587 } 588 589 /** 590 * Pass action to server 591 * 592 * @param _action 593 * @param _elems 594 */ 595 action(_action, _elems) 596 { 597 let paths = this._elems2paths(_elems); 598 let path = this.get_path(_action && _action.parent.data.nextmatch.getInstanceManager().uniqueId || false); 599 this._do_action(_action.id, paths,true, path); 600 } 601 602 /** 603 * Prompt user for directory to create 604 * 605 * @param {egwAction|undefined} action Action, event or undefined if called directly 606 * @param {egwActionObject[] | undefined} selected Selected row, or undefined if called directly 607 */ 608 createdir(action, selected) 609 { 610 let self = this; 611 et2_dialog.show_prompt(function(button, dir){ 612 if (button && dir) 613 { 614 let path = self.get_path(action && action.parent ? action.parent.data.nextmatch.getInstanceManager().uniqueId : false); 615 if(action && action instanceof egwAction) 616 { 617 let paths = self._elems2paths(selected); 618 if(paths[0]) path = paths[0]; 619 // check if target is a file --> use it's directory instead 620 if(selected[0].id || path) 621 { 622 let data = egw.dataGetUIDdata(selected[0].id || 'filemanager::'+path ); 623 if (data && data.data.mime != 'httpd/unix-directory') 624 { 625 path = self.dirname(path); 626 } 627 } 628 } 629 self._do_action('createdir', egw.encodePathComponent(dir), true, path); // true=synchronous request 630 self.change_dir((path == '/' ? '' : path)+'/'+ egw.encodePathComponent(dir)); 631 } 632 },this.egw.lang('New directory'),this.egw.lang('Create directory')); 633 } 634 635 /** 636 * Prompt user for directory to create 637 */ 638 symlink() 639 { 640 let self = this; 641 et2_dialog.show_prompt(function (button, target) { 642 if (button && target) 643 { 644 self._do_action('symlink', target); 645 } 646 },this.egw.lang('Link target'), this.egw.lang('Create link')); 647 } 648 649 /** 650 * Run a serverside action via an ajax call 651 * 652 * @param _type 'move_file', 'copy_file', ... 653 * @param _selected selected paths 654 * @param _sync send a synchronous ajax request 655 * @param _path defaults to current path 656 */ 657 _do_action(_type, _selected, _sync?, _path?) 658 { 659 if (typeof _path == 'undefined') _path = this.get_path(); 660 egw.json('filemanager_ui::ajax_action', [_type, _selected, _path], 661 this._do_action_callback, this, !_sync, this 662 ).sendRequest(); 663 } 664 665 /** 666 * Callback for _do_action ajax call 667 * 668 * @param _data 669 */ 670 _do_action_callback(_data) 671 { 672 window.egw_refresh(_data.msg, this.appname, undefined, undefined, undefined, undefined, undefined, _data.type); 673 } 674 675 /** 676 * Force download of a file by appending '?download' to it's download url 677 * 678 * @param _action 679 * @param _senders 680 */ 681 force_download(_action, _senders) : boolean 682 { 683 for(let i = 0; i < _senders.length; i++) 684 { 685 let data = egw.dataGetUIDdata(_senders[i].id); 686 let url = data ? data.data.download_url : '/webdav.php'+this.id2path(_senders[i].id); 687 if (url[0] == '/') url = egw.link(url); 688 689 let a = document.createElement('a'); 690 if(typeof a.download == "undefined") 691 { 692 window.location = <Location><unknown>(url+"?download"); 693 return false; 694 } 695 696 // Multiple file download for those that support it 697 let $a = jQuery(a) 698 .prop('href', url) 699 .prop('download', data ? data.data.name : "") 700 .appendTo(this.et2.getDOMNode()); 701 702 window.setTimeout(jQuery.proxy(function() { 703 let evt = document.createEvent('MouseEvent'); 704 evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); 705 this[0].dispatchEvent(evt); 706 this.remove(); 707 }, $a), 100*i); 708 } 709 return false; 710 } 711 712 /** 713 * Check to see if the browser supports downloading multiple files 714 * (using a tag download attribute) to enable/disable the context menu 715 * 716 * @param {egwAction} action 717 * @param {egwActionObject[]} selected 718 */ 719 is_multiple_allowed(action, selected) : boolean 720 { 721 let allowed = typeof document.createElement('a').download != "undefined"; 722 723 if(typeof action == "undefined") return allowed; 724 725 return (allowed || selected.length <= 1) && action.not_disableClass.apply(action, arguments); 726 } 727 728 729 /** 730 * Change directory 731 * 732 * @param {string} _dir directory to change to incl. '..' for one up 733 * @param {et2_widget} widget 734 */ 735 change_dir(_dir, widget?) 736 { 737 for(var etemplate_name in this.path_widget) break; 738 if (widget) etemplate_name = widget.getInstanceManager().uniqueId; 739 740 // Make sure everything is in place for changing directory 741 if(!this.et2 || typeof etemplate_name !== 'string' || 742 typeof this.path_widget[etemplate_name] === 'undefined') 743 { 744 return false; 745 } 746 747 switch (_dir) 748 { 749 case '..': 750 _dir = this.dirname(this.get_path(etemplate_name)); 751 break; 752 case '~': 753 _dir = this.et2.getWidgetById('nm').options.settings.home_dir; 754 break; 755 } 756 757 this.path_widget[etemplate_name].set_value(_dir); 758 } 759 760 /** 761 * Toggle view between tiles and rows 762 * 763 * @param {string|Event} [view] - Specify what to change the view to. Either 'tile' or 'row'. 764 * Or, if this is used as a callback view is actually the event, and we need to find the view. 765 * @param {et2_widget} [button_widget] - The widget that's calling 766 */ 767 change_view(view, button_widget?) 768 { 769 let et2 = etemplate2.getById('filemanager-index'); 770 let nm : et2_nextmatch; 771 if(et2 && et2.widgetContainer.getWidgetById('nm')) 772 { 773 nm = et2.widgetContainer.getWidgetById('nm'); 774 } 775 if(!nm) 776 { 777 egw.debug('warn', 'Could not find nextmatch to change view'); 778 779 return; 780 } 781 782 if(!button_widget) 783 { 784 button_widget = (<et2_nextmatch><unknown>nm).getWidgetById('button[change_view]'); 785 } 786 if(button_widget && button_widget.instanceOf(et2_button)) 787 { 788 // Switch view based on button icon, since controller can get re-created 789 if(typeof view != 'string') 790 { 791 view = button_widget.options.image.replace('list_',''); 792 } 793 794 // Toggle button icon to the other view 795 //todo: nm.controller needs to be changed to nm.getController after merging typescript branch into master 796 button_widget.set_image("list_"+(view == et2_nextmatch_controller.VIEW_ROW ? et2_nextmatch_controller.VIEW_TILE : et2_nextmatch_controller.VIEW_ROW)); 797 798 button_widget.set_statustext(view == et2_nextmatch_controller.VIEW_ROW ? this.egw.lang("Tile view") : this.egw.lang('List view')); 799 } 800 801 nm.set_view(view); 802 // Put it into active filters (but don't refresh) 803 nm.activeFilters["view"]= view; 804 805 // Change template to match 806 let template : any = view == et2_nextmatch_controller.VIEW_ROW ? 'filemanager.index.rows' : 'filemanager.tile'; 807 nm.set_template(template); 808 809 // Wait for template to load, then refresh 810 template = nm.getWidgetById(template); 811 if(template && template.loading) 812 { 813 template.loading.done(function() { 814 nm.applyFilters({view: view}); 815 }); 816 } 817 } 818 819 /** 820 * Open/active an item 821 * 822 * @param _action 823 * @param _senders 824 */ 825 open(_action, _senders) 826 { 827 let data = egw.dataGetUIDdata(_senders[0].id); 828 let path = this.id2path(_senders[0].id); 829 this.et2 = this.et2 ? this.et2 : etemplate2.getById('filemanager-index').widgetContainer; 830 let mime = this.et2._inst.widgetContainer.getWidgetById('$row'); 831 // try to get mime widget DOM node out of the row DOM 832 let mime_dom = jQuery(_senders[0].iface.getDOMNode()).find("span#filemanager-index_\\$row"); 833 let fe = egw_get_file_editor_prefered_mimes(); 834 835 // symlinks dont have mime 'http/unix-directory', but server marks all directories with class 'isDir' 836 if (data.data.mime == 'httpd/unix-directory' || data.data['class'] && data.data['class'].split(/ +/).indexOf('isDir') != -1) 837 { 838 this.change_dir(path,_action.parent.data.nextmatch || this.et2); 839 } 840 else if(mime && data.data.mime.match(mime.mime_regexp) && mime_dom.length>0) 841 { 842 mime_dom.click(); 843 } 844 else if (mime && this.isEditable(_action, _senders) && fe && fe.edit) 845 { 846 847 egw.open_link(egw.link('/index.php', { 848 menuaction: fe.edit.menuaction, 849 path: decodeURIComponent(data.data.download_url) 850 }), '', fe.edit_popup); 851 } 852 else 853 { 854 let url; 855 // Build ViewerJS url 856 if (data.data.mime.match(/application\/vnd\.oasis\.opendocument/) && 857 egw.preference('document_doubleclick_action', 'filemanager') == 'collabeditor') 858 { 859 url = '/ViewerJS/#..' + data.data.download_url; 860 } 861 862 egw.open({path: path, type: data.data.mime, download_url: url}, 'file','view',null,'_browser'); 863 } 864 return false; 865 } 866 867 /** 868 * Edit prefs of current directory 869 * 870 * @param _action 871 * @param _senders 872 */ 873 editprefs(_action, _senders) 874 { 875 let path = typeof _senders != 'undefined' ? this.id2path(_senders[0].id) : this.get_path(_action && _action.parent.data.nextmatch.getInstanceManager().uniqueId || false); 876 877 egw().open_link(egw.link('/index.php', { 878 menuaction: 'filemanager.filemanager_ui.file', 879 path: path 880 }), 'fileprefs', '510x425'); 881 } 882 883 /** 884 * Callback to check if the paste action is enabled. We also update the 885 * clipboard historical targets here as well 886 * 887 * @param {egwAction} _action drop action we're checking 888 * @param {egwActionObject[]} _senders selected files 889 * @param {egwActionObject} _target Drop or context menu activated on this one 890 * 891 * @returns boolean true if enabled, false otherwise 892 */ 893 paste_enabled(_action, _senders, _target) 894 { 895 // Need files in the clipboard for this 896 let clipboard_files = this.get_clipboard_files(); 897 if(clipboard_files.length === 0) 898 { 899 return false; 900 } 901 902 // Parent action (paste) gets run through here as well, but needs no 903 // further processing 904 if(_action.id == 'paste') return true; 905 906 if(_action.canHaveChildren.indexOf('drop') == -1) 907 { 908 _action.canHaveChildren.push('drop'); 909 } 910 let actions = []; 911 912 // Current directory 913 let current_dir = this.get_path(); 914 let dir = egw.dataGetUIDdata('filemanager::'+current_dir); 915 let path_widget = etemplate2.getById('filemanager-index').widgetContainer.getWidgetById('button[createdir]'); 916 actions.push({ 917 id:_action.id+'_current', caption: current_dir, path: current_dir, 918 enabled: dir && dir.data && dir.data.class && dir.data.class.indexOf('noEdit') === -1 || 919 !dir && path_widget && !path_widget.options.readonly 920 }); 921 922 // Target, if directory 923 let target_dir = this.id2path(_target.id); 924 dir = egw.dataGetUIDdata(_target.id); 925 actions.push({ 926 id: _action.id + '_target', 927 caption: target_dir, 928 path: target_dir, 929 enabled: _target && _target.iface && jQuery(_target.iface.getDOMNode()).hasClass('isDir') && 930 (dir && dir.data && dir.data.class && dir.data.class.indexOf('noEdit') === -1 || !dir) 931 }); 932 933 // Last 10 folders 934 let previous_dsts = jQuery.extend([], <any><unknown>egw.preference('drop_history', this.appname)); 935 let action_index = 0; 936 for (let i = 0; i < 10; i++) 937 { 938 let path = i < previous_dsts.length ? previous_dsts[i] : ''; 939 actions.push({ 940 id: _action.id + '_target_' + action_index++, 941 caption: path, 942 path: path, 943 group: 2, 944 enabled: path && !(current_dir && path === current_dir || target_dir && path === target_dir) 945 }); 946 } 947 948 // Common stuff, every action needs these 949 for(let i = 0; i < actions.length; i++) 950 { 951 //actions[i].type = 'drop', 952 actions[i].acceptedTypes = _action.acceptedTypes; 953 actions[i].no_lang = true; 954 actions[i].hideOnDisabled = true; 955 } 956 957 _action.updateActions(actions); 958 959 // Create paste action 960 // This injects the clipboard data and calls the original handler 961 let paste_exec = function(action, selected) { 962 // Add in clipboard as a sender 963 let clipboard = JSON.parse(egw.getSessionItem('phpgwapi', 'egw_clipboard')); 964 965 // Set a flag so apps can tell the difference, if they need to 966 action.set_onExecute(action.parent.onExecute.fnct); 967 action.execute(clipboard.selected,selected[0]); 968 969 // Clear the clipboard, the files are not there anymore 970 if(action.id.indexOf('move') !== -1) 971 { 972 egw.setSessionItem('phpgwapi', 'egw_clipboard', JSON.stringify({ 973 type:[], 974 selected:[] 975 })); 976 } 977 }; 978 for(let i = 0; i < actions.length; i++) 979 { 980 _action.getActionById(actions[i].id).onExecute = jQuery.extend(true, {}, _action.onExecute); 981 982 _action.getActionById(actions[i].id).set_onExecute(paste_exec); 983 } 984 return actions.length > 0; 985 } 986 987 /** 988 * File(s) droped 989 * 990 * @param _action 991 * @param _elems 992 * @param _target 993 * @returns 994 */ 995 drop(_action, _elems, _target) 996 { 997 let src = this._elems2paths(_elems); 998 999 // Target will be missing ID if directory is empty 1000 // so start with the current directory 1001 let parent = _action; 1002 let nm = _target ? _target.manager.data.nextmatch : null; 1003 while(!nm && parent.parent) 1004 { 1005 parent = parent.parent; 1006 if(parent.data.nextmatch) nm = parent.data.nextmatch; 1007 } 1008 let nm_dst = this.get_path(nm.getInstanceManager().uniqueId || false); 1009 let dst; 1010 // Action specifies a destination, target does not matter 1011 if(_action.data && _action.data.path) 1012 { 1013 dst = _action.data.path; 1014 } 1015 // File(s) were dropped on a row, they want them inside 1016 else if(_target) 1017 { 1018 dst = ''; 1019 let paths = this._elems2paths([_target]); 1020 if(paths[0]) dst = paths[0]; 1021 1022 // check if target is a file --> use it's directory instead 1023 if(_target.id) 1024 { 1025 let data = egw.dataGetUIDdata(_target.id); 1026 if(!data || data.data.mime != 'httpd/unix-directory') 1027 { 1028 dst = this.dirname(dst); 1029 } 1030 } 1031 } 1032 1033 // Remember the target for next time 1034 let previous_dsts = jQuery.extend([], egw.preference('drop_history', this.appname)); 1035 previous_dsts.unshift(dst); 1036 previous_dsts = Array.from(new Set(previous_dsts)).slice(0, 9); 1037 egw.set_preference(this.appname, 'drop_history', previous_dsts); 1038 1039 // Actual action id will be something like file_drop_{move|copy|link}[_other_id], 1040 // but we need to send move, copy or link 1041 let action_id = _action.id.replace("file_drop_", '').split('_', 1)[0]; 1042 this._do_action(action_id, src, false, dst || nm_dst); 1043 } 1044 1045 /** 1046 * Handle a native / HTML5 file drop from system 1047 * 1048 * This is a callback from nextmatch to prevent the default link action, and just upload instead. 1049 * 1050 * @param {string} row_uid UID of the row the files were dropped on 1051 * @param {Files[]} files 1052 */ 1053 filedrop(row_uid, files) : boolean 1054 { 1055 let self = this; 1056 let data = egw.dataGetUIDdata(row_uid); 1057 files = files || window.event.dataTransfer.files; 1058 1059 let path = typeof data != 'undefined' && data.data.mime == "httpd/unix-directory" ? data.data.path : this.get_path(); 1060 let widget = this.et2.getWidgetById('upload'); 1061 1062 // Override finish to specify a potentially different path 1063 let old_onfinishone = widget.options.onFinishOne; 1064 let old_onfinish = widget.options.onFinish; 1065 1066 widget.options.onFinishOne = function(_event, _file_count) { 1067 self.upload(_event, _file_count, path); 1068 }; 1069 1070 widget.options.onFinish = function() { 1071 widget.options.onFinish = old_onfinish; 1072 widget.options.onFinishOne = old_onfinishone; 1073 }; 1074 // This triggers the upload 1075 widget.set_value(files); 1076 1077 // Return false to prevent the link 1078 return false; 1079 } 1080 1081 /** 1082 * Change readonly state for given directory 1083 * 1084 * Get call/transported with each get_rows call, but should only by applied to UI if matching curent dir 1085 * 1086 * @param {string} _path 1087 * @param {boolean} _ro 1088 */ 1089 set_readonly(_path, _ro) 1090 { 1091 //alert('set_readonly("'+_path+'", '+_ro+')'); 1092 if (!this.path_widget) // widget not yet ready, try later 1093 { 1094 this.readonly = [_path, _ro]; 1095 return; 1096 } 1097 for(let id in this.path_widget) 1098 { 1099 let path = this.get_path(id); 1100 1101 if (_path == path) 1102 { 1103 let ids = ['button[linkpaste]', 'button[paste]', 'button[createdir]', 'button[symlink]', 'upload', 'new']; 1104 for(let i=0; i < ids.length; ++i) 1105 { 1106 let widget = etemplate2.getById(id).widgetContainer.getWidgetById(ids[i]); 1107 if (widget) 1108 { 1109 widget.set_readonly(_ro); 1110 } 1111 } 1112 } 1113 } 1114 } 1115 1116 /** 1117 * Row or filename in select-file dialog clicked 1118 * 1119 * @param {jQuery.event} event 1120 * @param {et2_widget} widget 1121 */ 1122 select_clicked(event, widget) : boolean 1123 { 1124 if (widget?.value?.is_dir) // true for "httpd/unix-directory" and "egw/*" 1125 { 1126 let path = null; 1127 // Cannot do this, there are multiple widgets named path 1128 // widget.getRoot().getWidgetById("path"); 1129 widget.getRoot().iterateOver(function(widget) { 1130 if(widget.id == "path") path = widget; 1131 },null, et2_textbox); 1132 if(path) 1133 { 1134 path.set_value(widget.value.path); 1135 } 1136 } 1137 else if (this.et2 && this.et2.getArrayMgr('content').getEntry('mode') != 'open-multiple') 1138 { 1139 let editfield = this.et2.getWidgetById('name'); 1140 if(editfield) 1141 { 1142 editfield.set_value(widget.value.name); 1143 } 1144 } 1145 else 1146 { 1147 let file = widget.value.name; 1148 widget.getParent().iterateOver(function(widget) 1149 { 1150 if(widget.options.selected_value == file) 1151 { 1152 widget.set_value(widget.get_value() == file ? widget.options.unselected_value : file); 1153 } 1154 }, null, et2_checkbox); 1155 } 1156 // Stop event or it will toggle back off 1157 event.preventDefault(); 1158 event.stopPropagation(); 1159 return false; 1160 } 1161 1162 /** 1163 * Set Sudo button's label and change its onclick handler according to its action 1164 * 1165 * @param {widget object} _widget sudo buttononly 1166 * @param {string} _action string of action type {login|logout} 1167 */ 1168 set_sudoButton(_widget, _action: string) 1169 { 1170 let widget = _widget || this.et2.getWidgetById('sudouser'); 1171 if (widget) 1172 { 1173 switch (_action) 1174 { 1175 case 'login': 1176 widget.set_label('Logout'); 1177 widget.getRoot().getInstanceManager().submit(widget); 1178 break; 1179 1180 default: 1181 widget.set_label('Superuser'); 1182 widget.onclick = function(){ 1183 jQuery('.superuser').css('display','inline'); 1184 }; 1185 } 1186 } 1187 } 1188 1189 /** 1190 * Open file a file dialog from EPL, warn if EPL is not available 1191 */ 1192 fileafile() 1193 { 1194 if (this.egw.user('apps').stylite) 1195 { 1196 this.egw.open_link('/index.php?menuaction=stylite.stylite_filemanager.upload&path='+this.get_path(), '_blank', '670x320'); 1197 } 1198 else 1199 { 1200 // This is shown if stylite code is there, but the app is not available 1201 et2_dialog.show_dialog(function(_button) 1202 { 1203 if (_button == et2_dialog.YES_BUTTON) window.open('http://www.egroupware.org/EPL', '_blank'); 1204 return true; 1205 }, this.egw.lang('this feature is only available in epl version.')+"\n\n"+ 1206 this.egw.lang('You can use regular upload [+] button to upload files.')+"\n\n"+ 1207 this.egw.lang('Do you want more information about EPL subscription?'), 1208 this.egw.lang('File a file'), undefined, et2_dialog.BUTTONS_YES_NO, et2_dialog.QUESTION_MESSAGE); 1209 } 1210 } 1211 1212 /** 1213 * create a share-link for the given entry 1214 * Overriden from parent to handle empty directories 1215 * 1216 * @param {egwAction} _action egw actions 1217 * @param {egwActionObject[]} _senders selected nm row 1218 * @param {egwActionObject} _target Drag source. Not used here. 1219 * @param {Boolean} _writable Allow edit access from the share. 1220 * @param {Boolean} _files Allow access to files from the share. 1221 * @param {Function} _callback Callback with results 1222 * @returns {Boolean} returns false if not successful 1223 */ 1224 share_link(_action, _senders, _target, _writable, _files, _callback) 1225 { 1226 // Check to see if we're in the empty row (No matches found.) and use current path 1227 let path = _senders[0].id; 1228 if(!path) 1229 { 1230 _senders[0] = {id: this.get_path()}; 1231 } 1232 // Pass along any action data 1233 let _extra = {}; 1234 for(let i in _action.data) 1235 { 1236 if(i.indexOf('share') == 0) 1237 { 1238 _extra[i] = _action.data[i]; 1239 } 1240 } 1241 super.share_link(_action, _senders, _target, _writable, _files, _callback, _extra); 1242 } 1243 1244 /** 1245 * Share-link callback 1246 * @param {object} _data 1247 */ 1248 _share_link_callback(_data) 1249 { 1250 if(_data.msg || _data.share_link) window.egw_refresh(_data.msg, this.appname); 1251 console.log("_data", _data); 1252 let app = this; 1253 1254 let copy_link_to_clipboard = function (evt) 1255 { 1256 let $target = jQuery(evt.target); 1257 $target.select(); 1258 try 1259 { 1260 let successful = document.execCommand('copy'); 1261 if(successful) 1262 { 1263 egw.message(app.egw.lang('Share link copied into clipboard')); 1264 return true; 1265 } 1266 } 1267 catch (e) {} 1268 egw.message('Failed to copy the link!'); 1269 }; 1270 jQuery("body").on("click", "[name=share_link]", copy_link_to_clipboard); 1271 et2_createWidget("dialog", { 1272 callback: function() { 1273 jQuery("body").off("click", "[name=share_link]", copy_link_to_clipboard); 1274 return true; 1275 }, 1276 title: _data.title ? _data.title : (_data.writable || _data.action ==='shareWritableLink' ? 1277 this.egw.lang("Writable share link") : this.egw.lang("Readonly share link") 1278 ), 1279 template: _data.template, 1280 width: 450, 1281 value: {content:{ "share_link": _data.share_link }} 1282 }); 1283 } 1284 1285 /** 1286 * Check if a row can have the Hidden Uploads action 1287 * Needs to be a directory 1288 */ 1289 hidden_upload_enabled(_action: egwAction, _senders: egwActionObject[]) 1290 { 1291 if (_senders[0].id == 'nm') return false; 1292 let data = egw.dataGetUIDdata(_senders[0].id); 1293 let readonly = (data?.data.class || '').split(/ +/).indexOf('noEdit') >= 0; 1294 1295 // symlinks dont have mime 'http/unix-directory', but server marks all directories with class 'isDir' 1296 return (!_senders[0].id || data.data.is_dir && !readonly); 1297 } 1298 1299 /** 1300 * View the link from an existing share 1301 * (EPL only) 1302 * 1303 * @param {egwAction} _action The shareLink action 1304 * @param {egwActionObject[]} _senders The row clicked on 1305 */ 1306 view_link(_action, _senders) : boolean 1307 { 1308 let id = egw.dataGetUIDdata(_senders[0].id).data.share_id; 1309 egw.json('stylite_filemanager::ajax_view_link', [id], 1310 this._share_link_callback, this, true, this).sendRequest(); 1311 return true; 1312 } 1313 1314 /** 1315 * This function copies the selected file/folder entry as webdav link into clipboard 1316 * 1317 * @param {object} _action egw actions 1318 * @param {object} _senders selected nm row 1319 * @returns {Boolean} returns false if not successful 1320 */ 1321 copy_link(_action, _senders) : boolean 1322 { 1323 let data = egw.dataGetUIDdata(_senders[0].id); 1324 let url = data ? data.data.download_url : '/webdav.php'+this.id2path(_senders[0].id); 1325 if (url[0] == '/') url = egw.link(url); 1326 if (url.substr(0,4) == 'http' && url.indexOf('://') <= 5) { 1327 // it's already a full url 1328 } 1329 else 1330 { 1331 let hostUrl = new URL(window.location.href); 1332 url = hostUrl.origin + url; 1333 } 1334 1335 if (url) 1336 { 1337 let elem = jQuery(document.createElement('div')); 1338 let range; 1339 elem.text(url); 1340 elem.appendTo('body'); 1341 if (document.selection) 1342 { 1343 range = document.body.createTextRange(); 1344 range.moveToElementText(elem); 1345 range.select(); 1346 } 1347 else if (window.getSelection) 1348 { 1349 range = document.createRange(); 1350 range.selectNode(elem[0]); 1351 window.getSelection().removeAllRanges(); 1352 window.getSelection().addRange(range); 1353 } 1354 1355 let successful = false; 1356 try { 1357 successful = document.execCommand('copy'); 1358 if (successful) 1359 { 1360 egw.message(this.egw.lang('WebDav link copied into clipboard')); 1361 window.getSelection().removeAllRanges(); 1362 1363 return true; 1364 } 1365 } 1366 catch (e) {} 1367 egw.message('Failed to copy the link!'); 1368 elem.remove(); 1369 return false; 1370 } 1371 } 1372 1373 /** 1374 * Function to check wheter selected file is editable. ATM only .odt is supported. 1375 * 1376 * @param {object} _egwAction egw action object 1377 * @param {object} _senders object of selected row 1378 * 1379 * @returns {boolean} returns true if is editable otherwise false 1380 */ 1381 isEditable(_egwAction, _senders) : boolean 1382 { 1383 if (_senders.length>1) return false; 1384 let data = egw.dataGetUIDdata(_senders[0].id); 1385 let mime = this.et2.getInstanceManager().widgetContainer.getWidgetById('$row'); 1386 let fe = egw_get_file_editor_prefered_mimes(data.data.mime); 1387 if (fe && fe.mime && !fe.mime[data.data.mime]) return false; 1388 return !!data.data.mime.match(mime.mime_odf_regex); 1389 } 1390 1391 /** 1392 * Method to create a new document 1393 * @param {object} _action either action or node 1394 * @param {object} _selected either widget or selected row 1395 * 1396 * @return {boolean} returns true 1397 */ 1398 create_new(_action, _selected) : boolean 1399 { 1400 let fe = egw.link_get_registry('filemanager-editor'); 1401 if (fe && fe["edit"]) 1402 { 1403 egw.open_link(egw.link('/index.php', { 1404 menuaction: fe["edit"].menuaction 1405 }), '', fe["popup_edit"]); 1406 } 1407 return true; 1408 } 1409} 1410app.classes.filemanager = filemanagerAPP; 1411