/** * EGroupware - Filemanager - Javascript UI * * @link https://www.egroupware.org * @package filemanager * @author Ralf Becker * @copyright (c) 2008-21 by Ralf Becker * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License */ /*egw:uses /api/js/jsapi/egw_app.js; */ import {EgwApp, PushData} from "../../api/js/jsapi/egw_app"; import {et2_nextmatch} from "../../api/js/etemplate/et2_extension_nextmatch"; import {etemplate2} from "../../api/js/etemplate/etemplate2"; import {Et2Dialog} from "../../api/js/etemplate/Et2Dialog/Et2Dialog"; import {et2_file} from "../../api/js/etemplate/et2_widget_file"; import {et2_nextmatch_controller} from "../../api/js/etemplate/et2_extension_nextmatch_controller"; import {egw} from "../../api/js/jsapi/egw_global"; import {et2_selectbox} from "../../api/js/etemplate/et2_widget_selectbox"; import {et2_textbox} from "../../api/js/etemplate/et2_widget_textbox"; import {MIME_REGEX} from "../../api/js/etemplate/Expose/ExposeMixin"; import {egwAction} from "../../api/js/egw_action/egw_action"; /** * UI for filemanager * * This is the code of filemanager's app.ts to ensure proper loading/cache-invalidation for Collabora extending filemanagerAPP! */ export class filemanagerAPP extends EgwApp { /** * If pushData.acl has fields that can help filter based on ACL grants, list them * here and we can check them and ignore push messages if there is no ACL for that entry. * We don't use fields, but setting this allows our custom check to run. * @protected */ protected push_grant_fields : string[] = ['acl']; /** * If pushData.acl has fields that can help filter based on current nextmatch filters, * list them here and we can check and ignore push messages if the nextmatch filters do not exclude them. * We only check path, which is the ID, but setting this allows our custom check to run. * * @protected */ protected push_filter_fields : string[] = ['id']; /** * path widget, by template */ path_widget : {} = {}; /** * Are files cut into clipboard - need to be deleted at source on paste */ clipboard_is_cut : boolean = false; /** * Regexp to convert id to a path, use this.id2path(_id) */ private remove_prefix : RegExp = /^filemanager::/; private readonly; /** * Constructor * * @memberOf app.filemanager */ constructor() { // call parent super('filemanager'); // Loading filemanager in its tab and home causes us problems with // unwanted destruction, so we check for already existing path widgets let lists = etemplate2.getByApplication('home'); for (let i = 0; i < lists.length; i++) { if(lists[i].app == 'filemanager' && lists[i].widgetContainer.getWidgetById('path')) { this.path_widget[lists[i].uniqueId] = lists[i].widgetContainer.getWidgetById('path'); } } } /** * Destructor */ destroy(_app) { delete this.et2; // call parent super.destroy(_app) } /** * This function is called when the etemplate2 object is loaded * and ready. If you must store a reference to the et2 object, * make sure to clean it up in destroy(). * * @param et2 etemplate2 Newly ready object * @param {string} name template name */ et2_ready(et2,name) { // call parent super.et2_ready(et2, name); if (name === 'filemanager.admin') { this.changeMountScheme(); return; } let path_widget = this.et2.getWidgetById('path'); if(path_widget) // do NOT set not found path-widgets, as uploads works on first one only! { this.path_widget[et2.DOMContainer.id] = path_widget; // Bind to removal to remove from list jQuery(et2.DOMContainer).on('clear', function(e) { if (app.filemanager && app.filemanager.path_widget) delete app.filemanager.path_widget[e.target.id]; }); } if(this.et2.getWidgetById('nm')) { // Legacy JS only supports 2 arguments (event and widget), so set // to the actual function here this.et2.getWidgetById('nm').set_onfiledrop(jQuery.proxy(this.filedrop, this)); } // get clipboard from browser localstore and update button tooltips this.clipboard_tooltips(); // calling set_readonly for initial path if (this.et2.getArrayMgr('content').getEntry('initial_path_readonly')) { this.readonly = [this.et2.getArrayMgr('content').getEntry('nm[path]'), true]; } if (typeof this.readonly != 'undefined') { this.set_readonly.apply(this, this.readonly); delete this.readonly; } if (name == 'filemanager.index') { let fe = egw.link_get_registry('filemanager-editor'); let new_widget = this.et2.getWidgetById('new'); if(fe && fe["edit"]) { let new_options = this.et2.getArrayMgr('sel_options').getEntry('new'); new_widget.set_select_options(new_options); } else if(new_widget) { new_widget.set_disabled(true); } } } /** * Handle a push notification about entry changes from the websocket * * Overridden here so we can handle notifications about file in subdirectories * * @param pushData * @param {string} pushData.app application name * @param {(string|number)} pushData.id id of entry to refresh or null * @param {string} pushData.type either 'update', 'edit', 'delete', 'add' or null * - update: request just modified data from given rows. Sorting is not considered, * so if the sort field is changed, the row will not be moved. * - edit: rows changed, but sorting may be affected. Requires full reload. * - delete: just delete the given rows clientside (no server interaction neccessary) * - add: requires full reload for proper sorting * @param {object|null} pushData.acl Extra data for determining relevance. eg: owner or responsible to decide if update is necessary * @param {number} pushData.account_id User that caused the notification */ push(pushData : PushData) { super.push(pushData); // don't care about other apps data if(pushData.app !== this.appname) { return; } // Special handling only for sub-dirs, super.push() handled everything else if(pushData.id == this.get_path() || !pushData.id.toString().startsWith(this.get_path())) { return; } // Nextmatch controller has the sub-grids. let nm = this.et2?.getDOMWidgetById('nm'); if(!nm) { return; } nm.controller._children.forEach((subgrid) => { if(subgrid._parentId === this.dirname(pushData.id)) { console.log(subgrid); let uid = pushData.id.toString().indexOf(nm.controller.dataStorePrefix) == 0 ? pushData.id : nm.controller.dataStorePrefix + "::" + pushData.id; debugger; nm._refresh_grid(pushData.type, subgrid, [pushData.id], uid); } }); } /** * Set the application's state to the given state. * * Extended from parent to also handle view * * * @param {{name: string, state: object}|string} state Object (or JSON string) for a state. * Only state is required, and its contents are application specific. * * @return {boolean} false - Returns false to stop event propagation */ setState(state) { // State should be an object, not a string, but we'll parse if(typeof state == "string") { if(state.indexOf('{') != -1 || state =='null') { state = JSON.parse(state); } } let result = super.setState(state, 'filemanager.index'); // This has to happen after the parent, changing to tile recreates // nm controller if(typeof state == "object" && state.state && state.state.view) { let et2 = etemplate2.getById('filemanager-index'); if(et2) { this.et2 = et2.widgetContainer; this.change_view(state.state.view); } } return result; } /** * Retrieve the current state of the application for future restoration * * Extended from parent to also set view * * @return {object} Application specific map representing the current state */ getState() { let state = super.getState(); let et2 = etemplate2.getById('filemanager-index'); if(et2) { let nm = et2.widgetContainer.getWidgetById('nm'); state.view = nm.view; } return state; } /** * Check grants to see if we can quickly tell if this entry is not for us * * Overridden to check current user and their memberships against pushData.acl, which is a list of account IDs * that should have at least read access. * * @param pushData * @param grant_fields List of fields in pushData.acl with account IDs that might grant access eg: info_responsible * @param appname Optional, to check against the grants for a different application. Defaults to this.appname. * * @return boolean Entry has ACL access */ _push_grant_check(pushData : PushData, grant_fields : string[], appname? : string) : boolean { let grants = [this.egw.user("account_id"), ...this.egw.user("memberships")]; // check user has a something in the pushData ACL list // Don't use strict comparison since sometimes account IDs are strings, sometimes ints return grants.filter(value => pushData.acl.find(acl => acl == value)).length > 0; } /** * Check pushData path to see if we care about this entry based on current nextmatch path. * This is not a definitive yes or no (the server will tell us when we ask), we just want to cheaply * avoid a server call if we know it won't be in the list. * * @param pushData * @param filter_fields List of filter field names eg: [owner, cat_id] * @return boolean True if the nextmatch filters might include the entry, false if not */ _push_field_filter(pushData : PushData, nm : et2_nextmatch, filter_fields : string[]) : boolean { return pushData.id && this.dirname(pushData.id) === this.get_path(); } /** * Convert id to path (remove "filemanager::" prefix) */ id2path(_id : string) : string { return _id.replace(this.remove_prefix, ''); } /** * Convert array of elems to array of paths */ _elems2paths(_elems) : string[] { let paths = []; for (let i = 0; i < _elems.length; i++) { // If selected has no id, try parent. This happens for the placeholder row // in empty directories. paths.push(_elems[i].id? this.id2path(_elems[i].id) : _elems[i]._context._parentId); } return paths; } /** * Get directory of a path */ dirname(_path : string) : string { let parts = _path.split('/'); parts.pop(); return parts.join('/') || '/'; } /** * Get name of a path */ basename(_path : string) : string { return _path.split('/').pop(); } /** * Get current working directory */ get_path(etemplate_name? : string) : string { if(!etemplate_name || typeof this.path_widget[etemplate_name] == 'undefined') { for(etemplate_name in this.path_widget) break; } let path_widget = this.path_widget[etemplate_name]; return path_widget ? path_widget.get_value.apply(path_widget) : null; } /** * Open compose with already attached files * * @param {(string|string[])} attachments path(s) * @param {object} params */ open_mail(attachments : string | string[], params? : object) { if (typeof attachments == 'undefined') attachments = this.get_clipboard_files(); if (!params || typeof params != 'object') params = {}; if (!(attachments instanceof Array)) attachments = [ attachments ]; let content = {data:{files:{file:[]}}}; for(let i=0; i < attachments.length; i++) { params['preset[file]['+i+']'] = 'vfs://default'+attachments[i]; content.data.files.file.push('vfs://default'+attachments[i]); } content.data.files["filemode"] = params['preset[filemode]']; // always open compose in html mode, as attachment links look a lot nicer in html params["mimeType"] = 'html'; return egw.openWithinWindow("mail", "setCompose", content, params, /mail.mail_compose.compose/, true); } /** * Mail files action: open compose with already attached files * * @param _action * @param _elems */ mail(_action, _elems) { this.open_mail(this._elems2paths(_elems), { 'preset[filemode]': _action.id.substr(5) }); } /** * Copy a share link to the system clipboard * * @param widget */ copy_share_link(ev, widget) { egw.copyTextToClipboard(widget.value, widget, ev).then((success) => { if(success !== false) { egw.message(this.egw.lang('share link copied into clipboard')); } }); } /** * Mail files action: open compose with already linked files * We're only interested in hidden upload shares here, open_mail can handle * the rest * * @param {egwAction} _action * @param {egwActionObject[]} _selected */ mail_share_link(_action, _selected) { if(_action.id !== 'mail_shareUploadDir') { return this.mail(_action, _selected); } let path = this.id2path(_selected[0].id); this.share_link(_action, _selected, null, false, false, this._mail_link_callback); return true; } /** * Callback with the share link to append to an email * * @param {Object} _data * @param {String} _data.share_link Link to the share * @param {String} _data.title Title for the link * @param {String} [_data.msg] Error message */ _mail_link_callback(_data) { debugger; if (_data.msg || !_data.share_link) window.egw_refresh(_data.msg, this.appname); let params = { 'preset[body]': ''+_data.title+'', 'mimeType': 'html'// always open compose in html mode, as attachment links look a lot nicer in html }; let content = { mail_htmltext: ['
'+_data.title+''], mail_plaintext: ["\n"+_data.share_link] }; return egw.openWithinWindow("mail", "setCompose", content, params, /mail.mail_compose.compose/); } /** * Trigger Upload after each file is uploaded * @param {type} _event */ uploadOnOne(_event) { this.upload(_event,1); } /** * Send names of uploaded files (again) to server, to process them: either copy to vfs or ask overwrite/rename * * @param {event} _event * @param {number} _file_count * @param {string=} _path where the file is uploaded to, default current directory * @param {string} _conflict What to do if the file conflicts with one on the server * @param {string} _target Upload processing target. Sharing classes can override this. */ upload(_event, _file_count : number, _path? : string, _conflict = "ask", _target: string = 'filemanager_ui::ajax_action') { if(typeof _path == 'undefined') { _path = this.get_path(); } if (_file_count && !jQuery.isEmptyObject(_event.data.getValue())) { let widget = _event.data; let value = widget.getValue(); value.conflict = _conflict; egw.json(_target, ['upload', value, _path, {ui_path: this.egw.window.location.pathname}], this._upload_callback, this, true, this ).sendRequest(); widget.set_value(''); } } /** * Finish callback for file a file dialog, to get the overwrite / rename prompt * * @param {event} _event * @param {number} _file_count */ file_a_file_upload(_event, _file_count : number) : boolean { let widget = _event.data; let path = widget.getRoot().getWidgetById("path").getValue(); let action = widget.getRoot().getWidgetById("action").getValue(); let link = widget.getRoot().getWidgetById("entry").getValue(); if(action == 'save_as' && link.app && link.id) { path = "/apps/"+link.app+"/"+link.id; } let props = widget.getInstanceManager().getValues(widget.getRoot()); egw.json('filemanager_ui::ajax_action', [action == 'save_as' ? 'upload' : 'link', widget.getValue(), path, props], function(_data) { app.filemanager._upload_callback(_data); // Remove successful after a delay for(var file in _data.uploaded) { if(!_data.uploaded[file].confirm || _data.uploaded[file].confirmed) { // Remove that file from file widget... widget.remove_file(_data.uploaded[file].name); } } opener.egw_refresh('','filemanager',null,null,'filemanager'); }, app.filemanager, true, this ).sendRequest(true); return true; } /** * Callback for server response to upload request: * - display message and refresh list * - ask use to confirm overwritting existing files or rename upload * * @param {object} _data values for attributes msg, files, ... */ _upload_callback(_data) { if(_data.msg || _data.uploaded) { if(this.egw.pushAvailable()) { this.egw.message(_data.msg, _data.errs > 0 ? "error" : "success"); } else { window.egw_refresh(_data.msg, this.appname, undefined, undefined, undefined, undefined, undefined, _data.type); } } let that = this; for(let file in _data.uploaded) { if(_data.uploaded[file].confirm && !_data.uploaded[file].confirmed) { let buttons = [ { label: this.egw.lang("Yes"), id: "overwrite", class: "ui-priority-primary", "default": true, image: 'check' }, {label: this.egw.lang("Rename"), id: "rename", image: 'edit'}, {label: this.egw.lang("Cancel"), id: "cancel", image: "cancel"} ]; if(_data.uploaded[file].confirm === "is_dir") { buttons.shift(); } let dialog = Et2Dialog.show_prompt(function(_button_id, _value) { let uploaded = {}; uploaded[this.my_data.file] = this.my_data.data; switch(_button_id) { case "overwrite": uploaded[this.my_data.file].confirmed = true; // fall through case "rename": uploaded[this.my_data.file].name = _value; delete uploaded[this.my_data.file].confirm; // send overwrite-confirmation and/or rename request to server egw.json('filemanager_ui::ajax_action', [this.my_data.action, uploaded, this.my_data.path, this.my_data.props], that._upload_callback, that, true, that ).sendRequest(); return; case "cancel": // Remove that file from every file widget... that.et2.iterateOver(function(_widget) { _widget.remove_file(this.my_data.data.name); }, this, et2_file); } }, _data.uploaded[file].confirm === "is_dir" ? this.egw.lang("There's already a directory with that name!") : this.egw.lang('Do you want to overwrite existing file %1 in directory %2?', _data.uploaded[file].name, _data.path), this.egw.lang('File %1 already exists', _data.uploaded[file].name), _data.uploaded[file].name, buttons, file); // setting required data for callback in as my_data dialog.my_data = { action: _data.action, file: file, path: _data.path, data: _data.uploaded[file], props: _data.props }; } } } /** * Get any files that are in the system clipboard * * @return {string[]} Paths */ get_clipboard_files() { let clipboard_files = []; if (typeof window.localStorage != 'undefined' && typeof egw.getSessionItem('phpgwapi', 'egw_clipboard') != 'undefined') { let clipboard = JSON.parse(egw.getSessionItem('phpgwapi', 'egw_clipboard')) || { type:[], selected:[] }; if(clipboard.type.indexOf('file') >= 0) { for(let i = 0; i < clipboard.selected.length; i++) { let split = clipboard.selected[i].id.split('::'); if(split[0] == 'filemanager') { clipboard_files.push(this.id2path(clipboard.selected[i].id)); } } } } return clipboard_files; } /** * Update clickboard tooltips in buttons */ clipboard_tooltips() { let paste_buttons = ['button[paste]', 'button[linkpaste]', 'button[mailpaste]']; for(let i=0; i < paste_buttons.length; ++i) { let button = this.et2.getWidgetById(paste_buttons[i]); if (button) button.set_statustext(this.get_clipboard_files().join(",\n")); } } /** * Clip files into clipboard * * @param _action * @param _elems */ clipboard(_action, _elems) { this.clipboard_is_cut = _action.id == "cut"; let clipboard = JSON.parse(egw.getSessionItem('phpgwapi', 'egw_clipboard')) || { type:[], selected:[] }; if(_action.id != "add") { clipboard = { type:[], selected:[] }; } // When pasting we need to know the type of data - pull from actions let drag = _elems[0].getSelectedLinks('drag').links; for(let k in drag) { if(drag[k].enabled && drag[k].actionObj.dragType.length > 0) { clipboard.type = clipboard.type.concat(drag[k].actionObj.dragType); } } clipboard.type = jQuery.unique(clipboard.type); // egwAction is a circular structure and can't be stringified so just take what we want // Hopefully that's enough for the action handlers for(let k in _elems) { if(_elems[k].id) clipboard.selected.push({id:_elems[k].id, data:_elems[k].data}); } // Save it in session egw.setSessionItem('phpgwapi', 'egw_clipboard', JSON.stringify(clipboard)); this.clipboard_tooltips(); } /** * Paste files into current directory or mail them * * @param _type 'paste', 'linkpaste', 'mailpaste' */ paste(_type : string) { let clipboard_files = this.get_clipboard_files(); if (clipboard_files.length == 0) { alert(this.egw.lang('Clipboard is empty!')); return; } switch(_type) { case 'mailpaste': this.open_mail(clipboard_files); break; case 'paste': this._do_action(this.clipboard_is_cut ? 'move' : 'copy', clipboard_files); if (this.clipboard_is_cut) { this.clipboard_is_cut = false; clipboard_files = []; this.clipboard_tooltips(); } break; case 'linkpaste': this._do_action('symlink', clipboard_files); break; } } /** * Pass action to server * * @param _action * @param _elems */ action(_action, _elems) { let paths = this._elems2paths(_elems); let path = this.get_path(_action && _action.parent.data.nextmatch.getInstanceManager().uniqueId || false); this._do_action(_action.id, paths,true, path); } /** * Prompt user for directory to create * * @param {egwAction|undefined} action Action, event or undefined if called directly * @param {egwActionObject[] | undefined} selected Selected row, or undefined if called directly */ createdir(action, selected) { let self = this; Et2Dialog.show_prompt(function(button, dir) { if(button && dir) { let path = self.get_path(action && action.parent ? action.parent.data.nextmatch.getInstanceManager().uniqueId : false); if(action && action instanceof egwAction) { let paths = self._elems2paths(selected); if(paths[0]) { path = paths[0]; } // check if target is a file --> use it's directory instead if(selected[0].id || path) { let data = egw.dataGetUIDdata(selected[0].id || 'filemanager::' + path); if(data && data.data.mime != 'httpd/unix-directory') { path = self.dirname(path); } } } self._do_action('createdir', egw.encodePathComponent(dir), false, path); } }, 'New directory', 'Create directory'); } /** * Prompt user for directory to create */ symlink() { let self = this; Et2Dialog.show_prompt(function(button, target) { if(button && target) { self._do_action('symlink', target); } }, 'Link target', 'Create link'); } /** * Run a serverside action via an ajax call * * @param _type 'move_file', 'copy_file', ... * @param _selected selected paths * @param _sync send a synchronous ajax request * @param _path defaults to current path */ _do_action(_type, _selected, _sync?, _path?) { if (typeof _path == 'undefined') _path = this.get_path(); egw.json('filemanager_ui::ajax_action', [_type, _selected, _path], this._do_action_callback, this, !_sync, this ).sendRequest(); } /** * Callback for _do_action ajax call * * @param _data */ _do_action_callback(_data) { if(this.egw.pushAvailable()) { // No need to refresh, push will handle it this.egw.message(_data.msg) return; } window.egw_refresh(_data.msg, this.appname, undefined, undefined, undefined, undefined, undefined, _data.type); } /** * Force download of a file by appending '?download' to it's download url * * @param _action * @param _senders */ force_download(_action, _senders) : boolean { for(let i = 0; i < _senders.length; i++) { let data = egw.dataGetUIDdata(_senders[i].id); let url = data ? data.data.download_url : '/webdav.php'+this.id2path(_senders[i].id); if (url[0] == '/') url = egw.link(url); let a = document.createElement('a'); if(typeof a.download == "undefined") { window.location = (url+"?download"); return false; } // Multiple file download for those that support it let $a = jQuery(a) .prop('href', url) .prop('download', data ? data.data.name : "") .appendTo(this.et2.getDOMNode()); window.setTimeout(jQuery.proxy(function() { let evt = document.createEvent('MouseEvent'); evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); this[0].dispatchEvent(evt); this.remove(); }, $a), 100*i); } return false; } /** * Check to see if the browser supports downloading multiple files * (using a tag download attribute) to enable/disable the context menu * * @param {egwAction} action * @param {egwActionObject[]} selected */ is_multiple_allowed(action, selected) : boolean { let allowed = typeof document.createElement('a').download != "undefined"; if(typeof action == "undefined") return allowed; return (allowed || selected.length <= 1) && action.not_disableClass.apply(action, arguments); } /** * Change directory * * @param {string} _dir directory to change to incl. '..' for one up * @param {et2_widget} widget */ change_dir(_dir, widget?) { for(var etemplate_name in this.path_widget) break; if (widget) etemplate_name = widget.getInstanceManager().uniqueId; // Make sure everything is in place for changing directory if(!this.et2 || typeof etemplate_name !== 'string' || typeof this.path_widget[etemplate_name] === 'undefined') { return false; } switch (_dir) { case '..': _dir = this.dirname(this.get_path(etemplate_name)); break; case '~': _dir = this.et2.getWidgetById('nm').options.settings.home_dir; break; } this.path_widget[etemplate_name].set_value(_dir); // Dispatch a change event for the nm this.path_widget[etemplate_name].dispatchEvent(new Event("change")); } /** * Toggle view between tiles and rows * * @param {string|Event} [view] - Specify what to change the view to. Either 'tile' or 'row'. * Or, if this is used as a callback view is actually the event, and we need to find the view. * @param {et2_widget} [button_widget] - The widget that's calling */ change_view(view, button_widget?) { let et2 = etemplate2.getById('filemanager-index'); let nm : et2_nextmatch; if(et2 && et2.widgetContainer.getWidgetById('nm')) { nm = et2.widgetContainer.getWidgetById('nm'); } if(!nm) { egw.debug('warn', 'Could not find nextmatch to change view'); return; } if(!button_widget) { button_widget = (nm).getWidgetById('button[change_view]'); } if(button_widget) { // Switch view based on button icon, since controller can get re-created if(typeof view != 'string') { view = button_widget.image.split('list_')[1].replace('.svg', ''); } // Toggle button icon to the other view //todo: nm.controller needs to be changed to nm.getController after merging typescript branch into master button_widget.image = ("list_" + (view == et2_nextmatch_controller.VIEW_ROW ? et2_nextmatch_controller.VIEW_TILE : et2_nextmatch_controller.VIEW_ROW)); button_widget.statustext = (view == et2_nextmatch_controller.VIEW_ROW ? this.egw.lang("Tile view") : this.egw.lang('List view')); } nm.set_view(view); // Put it into active filters (but don't refresh) nm.activeFilters["view"]= view; // Change template to match let template : any = view == et2_nextmatch_controller.VIEW_ROW ? 'filemanager.index.rows' : 'filemanager.tile'; nm.set_template(template); // Wait for template to load, then refresh template = nm.getWidgetById(template); if(template && template.loading) { template.loading.then(function() { nm.applyFilters({view: view}); }); } } /** * Open/active an item * * @param _action * @param _senders */ open(_action, _senders) { let data = egw.dataGetUIDdata(_senders[0].id); let path = this.id2path(_senders[0].id); this.et2 = this.et2 ? this.et2 : etemplate2.getById('filemanager-index').widgetContainer; let mime = this.et2._inst.widgetContainer.getWidgetById('$row'); // try to get mime widget DOM node out of the row DOM let mime_dom = jQuery(_senders[0].iface.getDOMNode()).find("et2-vfs-mime"); let fe = egw.file_editor_prefered_mimes(); // symlinks dont have mime 'http/unix-directory', but server marks all directories with class 'isDir' if (data.data.mime == 'httpd/unix-directory' || data.data['class'] && data.data['class'].split(/ +/).indexOf('isDir') != -1) { this.change_dir(path,_action.parent.data.nextmatch || this.et2); } else if(data.data.mime.match(MIME_REGEX) && mime_dom.length>0) { mime_dom.click(); } else if (mime && this.isEditable(_action, _senders) && fe && fe.edit) { egw.open_link(egw.link('/index.php', { menuaction: fe.edit.menuaction, path: decodeURIComponent(data.data.download_url) }), '', fe.edit_popup); } else { egw.open({path: path, type: data.data.mime, download_url: data.data.download_url}, 'file','view',null,'_browser'); } return false; } /** * Edit prefs of current directory * * @param _action * @param _senders */ editprefs(_action, _senders) { let path = typeof _senders != 'undefined' ? this.id2path(_senders[0].id) : this.get_path(_action && _action.parent.data.nextmatch.getInstanceManager().uniqueId || false); egw().open_link(egw.link('/index.php', { menuaction: 'filemanager.filemanager_ui.file', path: path }), 'fileprefs', '510x425'); } /** * Callback to check if the paste action is enabled. We also update the * clipboard historical targets here as well * * @param {egwAction} _action drop action we're checking * @param {egwActionObject[]} _senders selected files * @param {egwActionObject} _target Drop or context menu activated on this one * * @returns boolean true if enabled, false otherwise */ paste_enabled(_action, _senders, _target) { // Need files in the clipboard for this let clipboard_files = this.get_clipboard_files(); if(clipboard_files.length === 0) { return false; } // Parent action (paste) gets run through here as well, but needs no // further processing if(_action.id == 'paste') return true; if(_action.canHaveChildren.indexOf('drop') == -1) { _action.canHaveChildren.push('drop'); } let actions = []; // Current directory let current_dir = this.get_path(); let dir = egw.dataGetUIDdata('filemanager::'+current_dir); let path_widget = etemplate2.getById('filemanager-index').widgetContainer.getWidgetById('button[createdir]'); actions.push({ id:_action.id+'_current', caption: current_dir, path: current_dir, enabled: dir && dir.data && dir.data.class && dir.data.class.indexOf('noEdit') === -1 || !dir && path_widget && !path_widget.options?.readonly }); // Target, if directory let target_dir = this.id2path(_target.id); dir = egw.dataGetUIDdata(_target.id); actions.push({ id: _action.id + '_target', caption: target_dir, path: target_dir, enabled: _target && _target.iface && jQuery(_target.iface.getDOMNode()).hasClass('isDir') && (dir && dir.data && dir.data.class && dir.data.class.indexOf('noEdit') === -1 || !dir) }); // Last 10 folders let previous_dsts = jQuery.extend([], egw.preference('drop_history', this.appname)); let action_index = 0; for (let i = 0; i < 10; i++) { let path = i < previous_dsts.length ? previous_dsts[i] : ''; actions.push({ id: _action.id + '_target_' + action_index++, caption: path, path: path, group: 2, enabled: path && !(current_dir && path === current_dir || target_dir && path === target_dir) }); } // Common stuff, every action needs these for(let i = 0; i < actions.length; i++) { //actions[i].type = 'drop', actions[i].acceptedTypes = _action.acceptedTypes; actions[i].no_lang = true; actions[i].hideOnDisabled = true; } _action.updateActions(actions); // Create paste action // This injects the clipboard data and calls the original handler let paste_exec = function(action, selected) { // Add in clipboard as a sender let clipboard = JSON.parse(egw.getSessionItem('phpgwapi', 'egw_clipboard')); // Set a flag so apps can tell the difference, if they need to action.set_onExecute(action.parent.onExecute.functionToPerform); action.execute(clipboard.selected,selected[0]); // Clear the clipboard, the files are not there anymore if(action.id.indexOf('move') !== -1) { egw.setSessionItem('phpgwapi', 'egw_clipboard', JSON.stringify({ type:[], selected:[] })); } }; for(let i = 0; i < actions.length; i++) { _action.getActionById(actions[i].id).onExecute = _action.onExecute.clone(); _action.getActionById(actions[i].id).set_onExecute(paste_exec); } return actions.length > 0; } /** * File(s) droped * * @param _action * @param _elems * @param _target * @returns */ drop(_action, _elems, _target) { let src = this._elems2paths(_elems); // Target will be missing ID if directory is empty // so start with the current directory let parent = _action; let nm = _target ? _target.manager.data.nextmatch : null; while(!nm && parent.parent) { parent = parent.parent; if(parent.data.nextmatch) nm = parent.data.nextmatch; } let nm_dst = this.get_path(nm.getInstanceManager().uniqueId || false); let dst; // Action specifies a destination, target does not matter if(_action.data && _action.data.path) { dst = _action.data.path; } // File(s) were dropped on a row, they want them inside else if(_target) { dst = ''; let paths = this._elems2paths([_target]); if(paths[0]) dst = paths[0]; // check if target is a file --> use it's directory instead if(_target.id) { let data = egw.dataGetUIDdata(_target.id); if(!data || data.data.mime != 'httpd/unix-directory') { dst = this.dirname(dst); } } } // Remember the target for next time let previous_dsts = jQuery.extend([], egw.preference('drop_history', this.appname)); previous_dsts.unshift(dst); previous_dsts = Array.from(new Set(previous_dsts)).slice(0, 9); egw.set_preference(this.appname, 'drop_history', previous_dsts); // Actual action id will be something like file_drop_{move|copy|link}[_other_id], // but we need to send move, copy or link let action_id = _action.id.replace("file_drop_", '').split('_', 1)[0]; this._do_action(action_id, src, false, dst || nm_dst); } /** * Handle a native / HTML5 file drop from system * * This is a callback from nextmatch to prevent the default link action, and just upload instead. * * @param {string} row_uid UID of the row the files were dropped on * @param {Files[]} files */ filedrop(row_uid, files) : boolean { let self = this; let data = egw.dataGetUIDdata(row_uid); files = files || window.event.dataTransfer.files; let path = typeof data != 'undefined' && data.data.mime == "httpd/unix-directory" ? data.data.path : this.get_path(); let widget = this.et2.getWidgetById('upload'); // Override finish to specify a potentially different path let old_onfinishone = widget.options.onFinishOne; let old_onfinish = widget.options.onFinish; widget.options.onFinishOne = function(_event, _file_count) { self.upload(_event, _file_count, path); }; widget.options.onFinish = function() { widget.options.onFinish = old_onfinish; widget.options.onFinishOne = old_onfinishone; }; // This triggers the upload widget.set_value(files); // Return false to prevent the link return false; } /** * Change readonly state for given directory * * Get call/transported with each get_rows call, but should only by applied to UI if matching curent dir * * @param {string} _path * @param {boolean} _ro */ set_readonly(_path, _ro) { //alert('set_readonly("'+_path+'", '+_ro+')'); if (!this.path_widget) // widget not yet ready, try later { this.readonly = [_path, _ro]; return; } for(let id in this.path_widget) { let path = this.get_path(id); if (_path == path) { let ids = ['button[linkpaste]', 'button[paste]', 'button[createdir]', 'button[symlink]', 'upload', 'new']; for(let i=0; i < ids.length; ++i) { let widget = etemplate2.getById(id).widgetContainer.getWidgetById(ids[i]); if (widget) { widget.set_readonly(_ro); } } } } } /** * Row or filename in select-file dialog clicked * * @param {jQuery.event} event * @param {et2_widget} widget */ select_clicked(event, widget) : boolean { if (widget?.value?.is_dir) // true for "httpd/unix-directory" and "egw/*" { let path = null; // Cannot do this, there are multiple widgets named path // widget.getRoot().getWidgetById("path"); widget.getRoot().iterateOver(function(widget) { if(widget.id == "path") path = widget; },null, et2_textbox); if(path) { path.set_value(widget.value.path); } } else if (this.et2 && this.et2.getArrayMgr('content').getEntry('mode') != 'open-multiple') { let editfield = this.et2.getWidgetById('name'); if(editfield) { editfield.set_value(widget.value.name); } } else { let file = widget.value.name; widget.getParent().iterateOver(function(widget) { if(widget.options.selected_value == file) { widget.set_value(widget.get_value() == file ? widget.options.unselected_value : file); } }, null, et2_checkbox); } // Stop event or it will toggle back off event.preventDefault(); event.stopPropagation(); return false; } /** * Set Sudo button's label and change its onclick handler according to its action * * @param {widget object} _widget sudo buttononly * @param {string} _action string of action type {login|logout} */ set_sudoButton(_widget, _action: string) { let widget = _widget || this.et2.getWidgetById('sudouser'); if (widget) { switch (_action) { case 'login': widget.set_label('Logout'); widget.getRoot().getInstanceManager().submit(widget); break; default: widget.set_label('Superuser'); widget.onclick = function(){ jQuery('.superuser').css('display','inline'); }; } } } /** * Open file a file dialog from EPL, warn if EPL is not available */ fileafile() { if (this.egw.user('apps').stylite) { this.egw.open_link('/index.php?menuaction=stylite.stylite_filemanager.upload&path='+this.get_path(), '_blank', '670x320'); } else { // This is shown if stylite code is there, but the app is not available Et2Dialog.show_dialog(function(_button) { if(_button == Et2Dialog.YES_BUTTON) { window.open('http://www.egroupware.org/EPL', '_blank'); } return true; }, this.egw.lang('this feature is only available in epl version.') + "\n\n" + this.egw.lang('You can use regular upload [+] button to upload files.') + "\n\n" + this.egw.lang('Do you want more information about EPL subscription?'), this.egw.lang('File a file'), undefined, Et2Dialog.BUTTONS_YES_NO, Et2Dialog.QUESTION_MESSAGE); } } /** * create a share-link for the given entry * Overriden from parent to handle empty directories * * @param {egwAction} _action egw actions * @param {egwActionObject[]} _senders selected nm row * @param {egwActionObject} _target Drag source. Not used here. * @param {Boolean} _writable Allow edit access from the share. * @param {Boolean} _files Allow access to files from the share. * @param {Function} _callback Callback with results * @returns {Boolean} returns false if not successful */ share_link(_action, _senders, _target, _writable, _files, _callback) { // Check to see if we're in the empty row (No matches found.) and use current path let path = _senders[0].id; if(!path) { _senders[0] = {id: this.get_path()}; } // Pass along any action data let _extra = {}; for(let i in _action.data) { if(i.indexOf('share') == 0) { _extra[i] = _action.data[i]; } } super.share_link(_action, _senders, _target, _writable, _files, _callback, _extra); } /** * Share-link callback * @param {object} _data */ _share_link_callback(_data) { if(_data.msg || _data.share_link) window.egw_refresh(_data.msg, this.appname); console.log("_data", _data); let app = this; let copy_link_to_clipboard = function (evt) { let $target = jQuery(evt.target); $target.select(); try { let successful = document.execCommand('copy'); if(successful) { egw.message(app.egw.lang('Share link copied into clipboard')); return true; } } catch(e) { } egw.message('Failed to copy the link!'); }; jQuery("body").on("click", "[name=share_link]", copy_link_to_clipboard); let dialog = new Et2Dialog(this.egw); dialog.transformAttributes({ callback: function() { jQuery("body").off("click", "[name=share_link]", copy_link_to_clipboard); return true; }, title: _data.title ? _data.title : (_data.writable || _data.action === 'shareWritableLink' ? this.egw.lang("Writable share link") : this.egw.lang("Readonly share link") ), buttons: Et2Dialog.BUTTONS_OK, template: _data.template, width: 450, value: {content: {"share_link": _data.share_link}} }); document.body.appendChild(dialog); dialog.addEventListener("load", () => { dialog.template.widgetContainer.getWidgetById("share_link").onclick = copy_link_to_clipboard; }); } /** * Check if a row can have the Hidden Uploads action * Needs to be a directory */ hidden_upload_enabled(_action : egwAction, _senders : egwActionObject[]) { if(_senders[0].id == 'nm') { return false; } let data = egw.dataGetUIDdata(_senders[0].id); let readonly = (data?.data.class || '').split(/ +/).indexOf('noEdit') >= 0; // symlinks dont have mime 'http/unix-directory', but server marks all directories with class 'isDir' return (!_senders[0].id || data.data.is_dir && !readonly); } hiddenUploadOnOne(event) { let path = event.data?.options?.uploadPath || this.get_path() + "/Upload"; this.upload(event, 1, path, 'rename', 'EGroupware\\Filemanager\\Sharing\\HiddenUpload::ajax_action'); } /** * View the link from an existing share * (EPL only) * * @param {egwAction} _action The shareLink action * @param {egwActionObject[]} _senders The row clicked on */ view_link(_action, _senders) : boolean { let id = egw.dataGetUIDdata(_senders[0].id).data.share_id; egw.json('stylite_filemanager::ajax_view_link', [id], this._share_link_callback, this, true, this).sendRequest(); return true; } /** * This function copies the selected file/folder entry as webdav link into clipboard * * @param {object} _action egw actions * @param {object} _senders selected nm row * @returns {Boolean} returns false if not successful */ copy_link(_action, _senders) : boolean { let data = egw.dataGetUIDdata(_senders[0].id); let url = data ? data.data.download_url : '/webdav.php'+this.id2path(_senders[0].id); if (url[0] == '/') url = egw.link(url); if (url.substr(0,4) == 'http' && url.indexOf('://') <= 5) { // it's already a full url } else { let hostUrl = new URL(window.location.href); url = hostUrl.origin + url; } if (url) { let elem = jQuery(document.createElement('div')); let range; elem.text(url); elem.appendTo('body'); if (document.selection) { range = document.body.createTextRange(); range.moveToElementText(elem); range.select(); } else if (window.getSelection) { range = document.createRange(); range.selectNode(elem[0]); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); } let successful = false; try { successful = document.execCommand('copy'); if (successful) { egw.message(this.egw.lang('WebDav link copied into clipboard')); window.getSelection().removeAllRanges(); return true; } } catch (e) {} egw.message('Failed to copy the link!'); elem.remove(); return false; } } /** * Function to check wheter selected file is editable. ATM only .odt is supported. * * @param {object} _egwAction egw action object * @param {object} _senders object of selected row * * @returns {boolean} returns true if is editable otherwise false */ isEditable(_egwAction, _senders) : boolean { if (_senders.length>1) return false; let data = egw.dataGetUIDdata(_senders[0].id); let mime = this.et2.getInstanceManager().widgetContainer.getWidgetById('$row'); let fe = egw.file_editor_prefered_mimes(data.data.mime); if(!mime || fe && fe.mime && !fe.mime[data.data.mime]) { return false; } return !!data.data.mime.match(mime.mime_odf_regex); } /** * Method to create a new document * @param {object} _action either action or node * @param {object} _selected either widget or selected row * * @return {boolean} returns true */ create_new(_action, _selected) : boolean { let fe = egw.link_get_registry('filemanager-editor'); if (fe && fe["edit"]) { egw.open_link(egw.link('/index.php', { menuaction: fe["edit"].menuaction }), '', fe["popup_edit"]); } return true; } /** * Mount scheme change --> enable/disable user, pass and host */ changeMountScheme() { const grid = this.et2.getWidgetById('mounts'); const scheme = (grid.getWidgetById('url[scheme]'))?.get_value(); ['url[user]', 'url[pass]', 'url[host]', 'colon', 'at'].forEach((name) => { (grid.getWidgetById(name))?.set_disabled(scheme !== 'webdavs' && scheme !== 'smb'); }); if (scheme === 'vfs') { ['url[user]', 'at'].forEach((name) => { (grid.getWidgetById(name))?.set_disabled(false); }); } } }