/** * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc. * SPDX-License-Identifier: AGPL-3.0-or-later */ (function() { /** * Construct a new FileActions instance * @constructs FileActions * @memberof OCA.Files */ var FileActions = function() { this.initialize(); }; FileActions.TYPE_DROPDOWN = 0; FileActions.TYPE_INLINE = 1; FileActions.prototype = { /** @lends FileActions.prototype */ actions: {}, defaults: {}, icons: {}, /** * @deprecated */ currentFile: null, /** * Dummy jquery element, for events */ $el: null, _fileActionTriggerTemplate: null, /** * @private */ initialize: function() { this.clear(); // abusing jquery for events until we get a real event lib this.$el = $(''); $('body').append(this.$el); this._showMenuClosure = _.bind(this._showMenu, this); }, /** * Adds an event handler * * @param {String} eventName event name * @param {Function} callback */ on: function(eventName, callback) { this.$el.on(eventName, callback); }, /** * Removes an event handler * * @param {String} eventName event name * @param {Function} callback */ off: function(eventName, callback) { this.$el.off(eventName, callback); }, /** * Notifies the event handlers * * @param {String} eventName event name * @param {Object} data data */ _notifyUpdateListeners: function(eventName, data) { this.$el.trigger(new $.Event(eventName, data)); }, /** * Merges the actions from the given fileActions into * this instance. * * @param {OCA.Files.FileActions} fileActions instance of OCA.Files.FileActions */ merge: function(fileActions) { var self = this; // merge first level to avoid unintended overwriting _.each(fileActions.actions, function(sourceMimeData, mime) { var targetMimeData = self.actions[mime]; if (!targetMimeData) { targetMimeData = {}; } self.actions[mime] = _.extend(targetMimeData, sourceMimeData); }); this.defaults = _.extend(this.defaults, fileActions.defaults); this.icons = _.extend(this.icons, fileActions.icons); }, /** * @deprecated use #registerAction() instead */ register: function(mime, name, permissions, icon, action, displayName) { return this.registerAction({ name: name, mime: mime, permissions: permissions, icon: icon, actionHandler: action, displayName: displayName || name }); }, /** * Register action * * @param {OCA.Files.FileAction} action object */ registerAction: function (action) { var mime = action.mime; var name = action.name; var actionSpec = { action: function(fileName, context) { // Actions registered in one FileAction may be executed on a // different one (for example, due to the "merge" function), // so the listeners have to be updated on the FileActions // from the context instead of on the one in which it was // originally registered. if (context && context.fileActions) { context.fileActions._notifyUpdateListeners('beforeTriggerAction', {action: actionSpec, fileName: fileName, context: context}); } action.actionHandler(fileName, context); if (context && context.fileActions) { context.fileActions._notifyUpdateListeners('afterTriggerAction', {action: actionSpec, fileName: fileName, context: context}); } }, name: name, displayName: action.displayName, mime: mime, filename: action.filename, order: action.order || 0, icon: action.icon, iconClass: action.iconClass, permissions: action.permissions, type: action.type || FileActions.TYPE_DROPDOWN, altText: action.altText || '' }; if (_.isUndefined(action.displayName)) { actionSpec.displayName = t('files', name); } if (_.isFunction(action.render)) { actionSpec.render = action.render; } if (_.isFunction(action.shouldRender)) { actionSpec.shouldRender = action.shouldRender; } if (!this.actions[mime]) { this.actions[mime] = {}; } this.actions[mime][name] = actionSpec; this.icons[name] = action.icon; this._notifyUpdateListeners('registerAction', {action: action}); }, /** * Clears all registered file actions. */ clear: function() { this.actions = {}; this.defaults = {}; this.icons = {}; this.currentFile = null; }, /** * Sets the default action for a given mime type. * * @param {String} mime mime type * @param {String} name action name */ setDefault: function (mime, name) { this.defaults[mime] = name; this._notifyUpdateListeners('setDefault', {defaultAction: {mime: mime, name: name}}); }, /** * Returns a map of file actions handlers matching the given conditions * * @param {string} mime mime type * @param {string} type "dir" or "file" * @param {number} permissions permissions * @param {string} filename filename * * @return {Object.} map of action name to action spec */ get: function(mime, type, permissions, filename) { var actions = this.getActions(mime, type, permissions, filename); var filteredActions = {}; $.each(actions, function (name, action) { filteredActions[name] = action.action; }); return filteredActions; }, /** * Returns an array of file actions matching the given conditions * * @param {string} mime mime type * @param {string} type "dir" or "file" * @param {number} permissions permissions * @param {string} filename filename * * @return {Array.} array of action specs */ getActions: function(mime, type, permissions, filename) { var actions = {}; if (this.actions.all) { actions = $.extend(actions, this.actions.all); } if (type) {//type is 'dir' or 'file' if (this.actions[type]) { actions = $.extend(actions, this.actions[type]); } } if (mime) { var mimePart = mime.substr(0, mime.indexOf('/')); if (this.actions[mimePart]) { actions = $.extend(actions, this.actions[mimePart]); } if (this.actions[mime]) { actions = $.extend(actions, this.actions[mime]); } } var filteredActions = {}; var self = this; $.each(actions, function(name, action) { if (self.allowedPermissions(action.permissions, permissions) && self.allowedFilename(action.filename, filename)) { filteredActions[name] = action; } }); return filteredActions; }, allowedPermissions: function(actionPermissions, permissions) { return (actionPermissions === OC.PERMISSION_NONE || (actionPermissions & permissions)); }, allowedFilename: function(actionFilename, filename) { return (!filename || filename === '' || !actionFilename || actionFilename === '' || actionFilename === filename); }, /** * Returns the default file action handler for the given conditions * * @param {string} mime mime type * @param {string} type "dir" or "file" * @param {number} permissions permissions * * @return {OCA.Files.FileActions~actionHandler} action handler * * @deprecated use getDefaultFileAction instead */ getDefault: function (mime, type, permissions) { var defaultActionSpec = this.getDefaultFileAction(mime, type, permissions); if (defaultActionSpec) { return defaultActionSpec.action; } return undefined; }, /** * Returns the default file action handler for the current file * * @return {OCA.Files.FileActions~actionSpec} action spec * @since 8.2 */ getCurrentDefaultFileAction: function() { var mime = this.getCurrentMimeType(); var type = this.getCurrentType(); var permissions = this.getCurrentPermissions(); return this.getDefaultFileAction(mime, type, permissions); }, /** * Returns the default file action handler for the given conditions * * @param {string} mime mime type * @param {string} type "dir" or "file" * @param {number} permissions permissions * * @return {OCA.Files.FileActions~actionSpec} action spec * @since 8.2 */ getDefaultFileAction: function(mime, type, permissions) { var mimePart; if (mime) { mimePart = mime.substr(0, mime.indexOf('/')); } var name = false; if (mime && this.defaults[mime]) { name = this.defaults[mime]; } else if (mime && this.defaults[mimePart]) { name = this.defaults[mimePart]; } else if (type && this.defaults[type]) { name = this.defaults[type]; } else { name = this.defaults.all; } var actions = this.getActions(mime, type, permissions); return actions[name]; }, /** * Default function to render actions * * @param {OCA.Files.FileAction} actionSpec file action spec * @param {boolean} isDefault true if the action is a default one, * false otherwise * @param {OCA.Files.FileActionContext} context action context */ _defaultRenderAction: function(actionSpec, isDefault, context) { if (!isDefault) { var params = { name: actionSpec.name, nameLowerCase: actionSpec.name.toLowerCase(), displayName: actionSpec.displayName, icon: actionSpec.icon, iconClass: actionSpec.iconClass, altText: actionSpec.altText, hasDisplayName: !!actionSpec.displayName }; if (_.isFunction(actionSpec.icon)) { params.icon = actionSpec.icon(context.$file.attr('data-file'), context); } if (_.isFunction(actionSpec.iconClass)) { params.iconClass = actionSpec.iconClass(context.$file.attr('data-file'), context); } var $actionLink = this._makeActionLink(params, context); context.$file.find('a.name>span.fileactions').append($actionLink); $actionLink.addClass('permanent'); return $actionLink; } }, /** * Renders the action link element * * @param {Object} params action params */ _makeActionLink: function(params) { return $(OCA.Files.Templates['file_action_trigger'](params)); }, /** * Displays the file actions dropdown menu * * @param {string} fileName file name * @param {OCA.Files.FileActionContext} context rendering context */ _showMenu: function(fileName, context) { var menu; var $trigger = context.$file.closest('tr').find('.fileactions .action-menu'); $trigger.addClass('open'); $trigger.attr('aria-expanded', 'true'); menu = new OCA.Files.FileActionsMenu(); context.$file.find('td.filename').append(menu.$el); menu.$el.on('afterHide', function() { context.$file.removeClass('mouseOver'); $trigger.removeClass('open'); $trigger.attr('aria-expanded', 'false'); menu.remove(); }); context.$file.addClass('mouseOver'); menu.show(context); }, /** * Renders the menu trigger on the given file list row * * @param {Object} $tr file list row element * @param {OCA.Files.FileActionContext} context rendering context */ _renderMenuTrigger: function($tr, context) { // remove previous $tr.find('.action-menu').remove(); var $el = this._renderInlineAction({ name: 'menu', displayName: '', iconClass: 'icon-more', altText: t('files', 'Actions'), action: this._showMenuClosure }, false, context); $el.addClass('permanent'); $el.attr('aria-expanded', 'false'); }, /** * Renders the action element by calling actionSpec.render() and * registers the click event to process the action. * * @param {OCA.Files.FileAction} actionSpec file action to render * @param {boolean} isDefault true if the action is a default action, * false otherwise * @param {OCA.Files.FileActionContext} context rendering context */ _renderInlineAction: function(actionSpec, isDefault, context) { if (actionSpec.shouldRender) { if (!actionSpec.shouldRender(context)) { return; } } var renderFunc = actionSpec.render || _.bind(this._defaultRenderAction, this); var $actionEl = renderFunc(actionSpec, isDefault, context); if (!$actionEl || !$actionEl.length) { return; } $actionEl.on( 'click', { a: null }, function(event) { event.stopPropagation(); event.preventDefault(); if ($actionEl.hasClass('open')) { return; } var $file = $(event.target).closest('tr'); if ($file.hasClass('busy')) { return; } var currentFile = $file.find('td.filename'); var fileName = $file.attr('data-file'); context.fileActions.currentFile = currentFile; var callContext = _.extend({}, context); if (!context.dir && context.fileList) { callContext.dir = $file.attr('data-path') || context.fileList.getCurrentDirectory(); } if (!context.fileInfoModel && context.fileList) { callContext.fileInfoModel = context.fileList.getModelForFile(fileName); if (!callContext.fileInfoModel) { console.warn('No file info model found for file "' + fileName + '"'); } } actionSpec.action( fileName, callContext ); } ); return $actionEl; }, /** * Trigger the given action on the given file. * * @param {string} actionName action name * @param {OCA.Files.FileInfoModel} fileInfoModel file info model * @param {OCA.Files.FileList} [fileList] file list, for compatibility with older action handlers [DEPRECATED] * * @return {boolean} true if the action handler was called, false otherwise * * @since 8.2 */ triggerAction: function(actionName, fileInfoModel, fileList) { var actionFunc; var actions = this.get( fileInfoModel.get('mimetype'), fileInfoModel.isDirectory() ? 'dir' : 'file', fileInfoModel.get('permissions'), fileInfoModel.get('name') ); if (actionName) { actionFunc = actions[actionName]; } else { actionFunc = this.getDefault( fileInfoModel.get('mimetype'), fileInfoModel.isDirectory() ? 'dir' : 'file', fileInfoModel.get('permissions') ); } if (!actionFunc) { actionFunc = actions['Download']; } if (!actionFunc) { return false; } var context = { fileActions: this, fileInfoModel: fileInfoModel, dir: fileInfoModel.get('path') }; var fileName = fileInfoModel.get('name'); this.currentFile = fileName; if (fileList) { // compatibility with action handlers that expect these context.fileList = fileList; context.$file = fileList.findFileEl(fileName); } actionFunc(fileName, context); }, /** * Display file actions for the given element * @param parent "td" element of the file for which to display actions * @param triggerEvent if true, triggers the fileActionsReady on the file * list afterwards (false by default) * @param fileList OCA.Files.FileList instance on which the action is * done, defaults to OCA.Files.App.fileList */ display: function (parent, triggerEvent, fileList) { if (!fileList) { console.warn('FileActions.display() MUST be called with a OCA.Files.FileList instance'); return; } this.currentFile = parent; var self = this; var $tr = parent.closest('tr'); var actions = this.getActions( this.getCurrentMimeType(), this.getCurrentType(), this.getCurrentPermissions(), this.getCurrentFile() ); var nameLinks; if ($tr.data('renaming')) { return; } // recreate fileactions container nameLinks = parent.children('a.name'); nameLinks.find('.fileactions, .nametext .action').remove(); nameLinks.append(''); var defaultAction = this.getDefaultFileAction( this.getCurrentMimeType(), this.getCurrentType(), this.getCurrentPermissions() ); var context = { $file: $tr, fileActions: this, fileList: fileList }; $.each(actions, function (name, actionSpec) { if (actionSpec.type === FileActions.TYPE_INLINE) { self._renderInlineAction( actionSpec, defaultAction && actionSpec.name === defaultAction.name, context ); } }); function objectValues(obj) { var res = []; for (var i in obj) { if (obj.hasOwnProperty(i)) { res.push(obj[i]); } } return res; } // polyfill if (!Object.values) { Object.values = objectValues; } var menuActions = Object.values(actions).filter(function (action) { return action.type !== OCA.Files.FileActions.TYPE_INLINE && (!defaultAction || action.name !== defaultAction.name) }); // do not render the menu if nothing is in it if (menuActions.length > 0) { this._renderMenuTrigger($tr, context); } if (triggerEvent){ fileList.$fileList.trigger(jQuery.Event("fileActionsReady", {fileList: fileList, $files: $tr})); } }, getCurrentFile: function () { return this.currentFile.parent().attr('data-file'); }, getCurrentMimeType: function () { return this.currentFile.parent().attr('data-mime'); }, getCurrentType: function () { return this.currentFile.parent().attr('data-type'); }, getCurrentPermissions: function () { return this.currentFile.parent().data('permissions'); }, /** * Register the actions that are used by default for the files app. */ registerDefaultActions: function() { this.registerAction({ name: 'Download', displayName: t('files', 'Download'), order: -20, mime: 'all', permissions: OC.PERMISSION_READ, iconClass: 'icon-download', actionHandler: function (filename, context) { var dir = context.dir || context.fileList.getCurrentDirectory(); var isDir = context.$file.attr('data-type') === 'dir'; var url = context.fileList.getDownloadUrl(filename, dir, isDir); var downloadFileaction = $(context.$file).find('.fileactions .action-download'); // don't allow a second click on the download action if(downloadFileaction.hasClass('disabled')) { return; } if (url) { var disableLoadingState = function() { context.fileList.showFileBusyState(filename, false); }; context.fileList.showFileBusyState(filename, true); OCA.Files.Files.handleDownload(url, disableLoadingState); } } }); this.registerAction({ name: 'Rename', displayName: t('files', 'Rename'), mime: 'all', order: -30, permissions: OC.PERMISSION_UPDATE, iconClass: 'icon-rename', actionHandler: function (filename, context) { context.fileList.rename(filename); } }); this.registerAction({ name: 'MoveCopy', displayName: function(context) { var permissions = context.fileInfoModel.attributes.permissions; if (permissions & OC.PERMISSION_UPDATE) { if (!context.fileInfoModel.canDownload()) { return t('files', 'Move'); } return t('files', 'Move or copy'); } return t('files', 'Copy'); }, mime: 'all', order: -25, permissions: $('#isPublic').val() ? OC.PERMISSION_UPDATE : OC.PERMISSION_READ, iconClass: 'icon-external', actionHandler: function (filename, context) { var permissions = context.fileInfoModel.attributes.permissions; var actions = OC.dialogs.FILEPICKER_TYPE_COPY; if (permissions & OC.PERMISSION_UPDATE) { if (!context.fileInfoModel.canDownload()) { actions = OC.dialogs.FILEPICKER_TYPE_MOVE; } else { actions = OC.dialogs.FILEPICKER_TYPE_COPY_MOVE; } } var dialogDir = context.dir; if (typeof context.fileList.dirInfo.dirLastCopiedTo !== 'undefined') { dialogDir = context.fileList.dirInfo.dirLastCopiedTo; } OC.dialogs.filepicker(t('files', 'Choose target folder'), function(targetPath, type) { if (type === OC.dialogs.FILEPICKER_TYPE_COPY) { context.fileList.copy(filename, targetPath, false, context.dir); } if (type === OC.dialogs.FILEPICKER_TYPE_MOVE) { context.fileList.move(filename, targetPath, false, context.dir); } context.fileList.dirInfo.dirLastCopiedTo = targetPath; }, false, "httpd/unix-directory", true, actions, dialogDir); } }); if (Boolean(OC.appswebroots.files_reminders) && Boolean(OC.appswebroots.notifications)) { this.registerAction({ name: 'SetReminder', displayName: function(_context) { return t('files', 'Set reminder'); }, mime: 'all', order: -24, icon: function(_filename, _context) { return OC.imagePath('files_reminders', 'alarm.svg') }, permissions: $('#isPublic').val() ? null : OC.PERMISSION_READ, actionHandler: function(_filename, _context) {}, }); } if (!/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && !!window.oc_current_user) { this.registerAction({ name: 'EditLocally', displayName: function(context) { var locked = context.$file.data('locked'); if (!locked) { return t('files', 'Edit locally'); } }, mime: 'all', order: -23, icon: function(filename, context) { var locked = context.$file.data('locked'); if (!locked) { return OC.imagePath('files', 'computer.svg') } }, permissions: OC.PERMISSION_UPDATE, actionHandler: function (filename, context) { var dir = context.dir || context.fileList.getCurrentDirectory(); var path = dir === '/' ? dir + filename : dir + '/' + filename; context.fileList.openLocalClient(path); }, }); } this.registerAction({ name: 'Open', mime: 'dir', permissions: OC.PERMISSION_READ, icon: '', actionHandler: function (filename, context) { let dir, id if (context.$file) { dir = context.$file.attr('data-path') id = context.$file.attr('data-id') } else { dir = context.fileList.getCurrentDirectory() id = context.fileId } if (OCA.Files.App && OCA.Files.App.getActiveView() !== 'files') { OCA.Files.App.setActiveView('files', {silent: true}); OCA.Files.App.fileList.changeDirectory(OC.joinPaths(dir, filename), true, true); } else { context.fileList.changeDirectory(OC.joinPaths(dir, filename), true, false, parseInt(id, 10)); } }, displayName: t('files', 'Open') }); this.registerAction({ name: 'Delete', displayName: function(context) { var mountType = context.$file.attr('data-mounttype'); var type = context.$file.attr('data-type'); var deleteTitle = (type && type === 'file') ? t('files', 'Delete file') : t('files', 'Delete folder') if (mountType === 'external-root') { deleteTitle = t('files', 'Disconnect storage'); } else if (mountType === 'shared-root') { deleteTitle = t('files', 'Leave this share'); } return deleteTitle; }, mime: 'all', order: 1000, // permission is READ because we show a hint instead if there is no permission permissions: OC.PERMISSION_DELETE, iconClass: 'icon-delete', actionHandler: function(fileName, context) { // if there is no permission to delete do nothing if((context.$file.data('permissions') & OC.PERMISSION_DELETE) === 0) { return; } context.fileList.do_delete(fileName, context.dir); $('.tipsy').remove(); // close sidebar on delete const path = context.dir + '/' + fileName if (OCA.Files.Sidebar && OCA.Files.Sidebar.file === path) { OCA.Files.Sidebar.close() } } }); this.setDefault('dir', 'Open'); } }; OCA.Files.FileActions = FileActions; /** * Replaces the button icon with a loading spinner and vice versa * - also adds the class disabled to the passed in element * * @param {jQuery} $buttonElement The button element * @param {boolean} showIt whether to show the spinner(true) or to hide it(false) */ OCA.Files.FileActions.updateFileActionSpinner = function($buttonElement, showIt) { var $icon = $buttonElement.find('.icon'); if (showIt) { var $loadingIcon = $(''); $icon.after($loadingIcon); $icon.addClass('hidden'); } else { $buttonElement.find('.icon-loading-small').remove(); $buttonElement.find('.icon').removeClass('hidden'); } }; /** * File action attributes. * * @todo make this a real class in the future * @typedef {Object} OCA.Files.FileAction * * @property {String} name identifier of the action * @property {(String|OCA.Files.FileActions~displayNameFunction)} displayName * display name string for the action, or function that returns the display name. * Defaults to the name given in name property * @property {String} mime mime type * @property {String} filename filename * @property {number} permissions permissions * @property {(Function|String)} icon icon path to the icon or function that returns it (deprecated, use iconClass instead) * @property {(String|OCA.Files.FileActions~iconClassFunction)} iconClass class name of the icon (recommended for theming) * @property {OCA.Files.FileActions~renderActionFunction} [render] optional rendering function * @property {OCA.Files.FileActions~actionHandler} actionHandler action handler function */ /** * File action context attributes. * * @typedef {Object} OCA.Files.FileActionContext * * @property {Object} $file jQuery file row element * @property {OCA.Files.FileActions} fileActions file actions object * @property {OCA.Files.FileList} fileList file list object */ /** * Render function for actions. * The function must render a link element somewhere in the DOM * and return it. The function should NOT register the event handler * as this will be done after the link was returned. * * @callback OCA.Files.FileActions~renderActionFunction * @param {OCA.Files.FileAction} actionSpec action definition * @param {Object} $row row container * @param {boolean} isDefault true if the action is the default one, * false otherwise * @return {Object} jQuery link object */ /** * Display name function for actions. * The function returns the display name of the action using * the given context information.. * * @callback OCA.Files.FileActions~displayNameFunction * @param {OCA.Files.FileActionContext} context action context * @return {String} display name */ /** * Icon class function for actions. * The function returns the icon class of the action using * the given context information. * * @callback OCA.Files.FileActions~iconClassFunction * @param {String} fileName name of the file on which the action must be performed * @param {OCA.Files.FileActionContext} context action context * @return {String} icon class */ /** * Action handler function for file actions * * @callback OCA.Files.FileActions~actionHandler * @param {String} fileName name of the file on which the action must be performed * @param context context * @param {String} context.dir directory of the file * @param {OCA.Files.FileInfoModel} fileInfoModel file info model * @param {Object} [context.$file] jQuery element of the file [DEPRECATED] * @param {OCA.Files.FileList} [context.fileList] the FileList instance on which the action occurred [DEPRECATED] * @param {OCA.Files.FileActions} context.fileActions the FileActions instance on which the action occurred */ // global file actions to be used by all lists OCA.Files.fileActions = new OCA.Files.FileActions(); })();