diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts new file mode 100644 index 0000000000000..cb4fc07033c05 --- /dev/null +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -0,0 +1,241 @@ +/** + * @copyright Copyright (c) 2023 John Molakvoæ + * + * @author John Molakvoæ + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import '@nextcloud/dialogs/style.css' +import { AxiosError } from 'axios' +import { getFilePickerBuilder, showError, type IFilePickerButton } from '@nextcloud/dialogs' +import { Permission, type Node, type View, registerFileAction, FileAction, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' + +import CopyIcon from 'vue-material-design-icons/FileMultiple.vue' +import FolderMoveSvg from '@mdi/svg/svg/folder-move.svg?raw' +import MoveIcon from 'vue-material-design-icons/FolderMove.vue' + +import { basename } from 'path' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import logger from '../logger' + +type ShareAttribute = { + enabled: boolean + key: string + scope: string +} + +enum MoveCopyAction { + MOVE = 'Move', + COPY = 'Copy', + MOVE_OR_COPY = 'move-or-copy', +} + +const canMove = (nodes: Node[]) => { + const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL) + return (minPermission & Permission.UPDATE) !== 0 +} + +const canDownload = (nodes: Node[]) => { + return nodes.every(node => { + const shareAttributes = JSON.parse(node.attributes?.['share-attributes'] ?? '[]') as Array + return shareAttributes.every(attribute => !(attribute.scope === 'permissions' && attribute.enabled === false && attribute.key === 'download')) + + }) +} + +const canCopy = (nodes: Node[]) => { + // For now the only restriction is that a shared file + // cannot be copied if the download is disabled + return canDownload(nodes) +} + +/** + * Return the action that is possible for the given nodes + * @param {Node[]} nodes The nodes to check against + * @return {MoveCopyAction} The action that is possible for the given nodes + */ +const getActionForNodes = (nodes: Node[]): MoveCopyAction => { + if (canMove(nodes)) { + if (canDownload(nodes)) { + return MoveCopyAction.MOVE_OR_COPY + } + return MoveCopyAction.MOVE + } + + // Assuming we can copy as the enabled checks for download permissions + return MoveCopyAction.COPY +} + +export const handleCopyMoveNodeTo = async (node: Node, destination: Node, method: MoveCopyAction.COPY | MoveCopyAction.MOVE) => { + if (!destination) { + return + } + + if (destination.type !== FileType.Folder) { + throw new Error(t('files', 'Destination is not a folder')) + } + + if (node.dirname === destination.path) { + throw new Error(t('files', 'This file/folder is already in that directory')) + } + + if (node.path === destination.path) { + throw new Error(t('files', 'You cannot move a file/folder onto itself')) + } + + const relativePath = `${destination.path}/${node.basename}`.replace(/\/\//, '/') + const destinationUrl = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}${relativePath}`) + logger.debug(`${method} ${node.basename} to ${destinationUrl}`) + + try { + await axios({ + method: method === MoveCopyAction.COPY ? 'COPY' : 'MOVE', + url: node.source, + headers: { + Destination: destinationUrl, + Overwrite: 'F', // Do not overwrite + }, + }) + } catch (error) { + if (error instanceof AxiosError) { + if (error?.response?.status === 412) { + throw new Error(t('files', 'A file or folder with that name already exists in this folder')) + } else if (error.message) { + throw new Error(error.message) + } + } + throw new Error() + } +} + +/** + * Open a file picker for the given action + * @param {MoveCopyAction} action The action to open the file picker for + * @param {string} dir The directory to start the file picker in + * @param {Node} node The node to move/copy + * @return {Promise} A promise that resolves to true if the action was successful + */ +const openFilePickerForAction = async (action: MoveCopyAction, dir = '/', node: Node): Promise => { + const filePicker = getFilePickerBuilder(t('files', 'Chose destination')) + .allowDirectories(true) + .setFilter((n: Node) => { + // We only want to show folders that we can create nodes in + return (n.permissions & Permission.CREATE) !== 0 + // We don't want to show the current node in the file picker + && node.fileid !== n.fileid + }) + .setMimeTypeFilter([]) + .setMultiSelect(false) + .startAt(dir) + + return new Promise((resolve, reject) => { + filePicker.setButtonFactory((nodes: Node[], path: string) => { + const buttons: IFilePickerButton[] = [] + const target = basename(path) + + if (node.dirname === path) { + // This file/folder is already in that directory + return buttons + } + + if (node.path === path) { + // You cannot move a file/folder onto itself + return buttons + } + + if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) { + buttons.push({ + label: target ? t('files', 'Copy to {target}', { target }) : t('files', 'Copy'), + type: 'primary', + icon: CopyIcon, + async callback(destination: Node[]) { + try { + await handleCopyMoveNodeTo(node, destination[0], MoveCopyAction.COPY) + resolve(true) + } catch (error) { + reject(error) + } + }, + }) + } + if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) { + buttons.push({ + label: target ? t('files', 'Move to {target}', { target }) : t('files', 'Move'), + type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary', + icon: MoveIcon, + async callback(destination: Node[]) { + try { + await handleCopyMoveNodeTo(node, destination[0], MoveCopyAction.MOVE) + resolve(true) + } catch (error) { + reject(error) + } + }, + }) + } + return buttons + }) + + const picker = filePicker.build() + picker.pick() + }) +} + +export const action = new FileAction({ + id: 'move-copy', + displayName(nodes: Node[]) { + switch (getActionForNodes(nodes)) { + case MoveCopyAction.MOVE: + return t('files', 'Move') + case MoveCopyAction.COPY: + return t('files', 'Copy') + case MoveCopyAction.MOVE_OR_COPY: + return t('files', 'Move or copy') + } + }, + iconSvgInline: () => FolderMoveSvg, + enabled(nodes: Node[]) { + // We only support moving/copying files within the user folder + if (!nodes.every(node => node.root?.startsWith('/files/'))) { + return false + } + return nodes.length > 0 && (canMove(nodes) || canCopy(nodes)) + }, + + async exec(node: Node, view: View, dir: string) { + const action = getActionForNodes([node]) + try { + await openFilePickerForAction(action, dir, node) + return true + } catch (error) { + if (error instanceof Error && !!error.message) { + showError(error.message) + // Silent action as we handle the toast + return null + } + return false + } + }, + + order: 15, +}) + +registerFileAction(action) diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts index 593baa493236e..278a374abfaca 100644 --- a/apps/files/src/main.ts +++ b/apps/files/src/main.ts @@ -5,6 +5,7 @@ import './actions/deleteAction' import './actions/downloadAction' import './actions/editLocallyAction' import './actions/favoriteAction' +import './actions/moveOrCopyAction' import './actions/openFolderAction' import './actions/openInFilesAction.js' import './actions/renameAction' @@ -14,6 +15,7 @@ import './actions/viewInFolderAction' import Vue from 'vue' import { createPinia, PiniaVuePlugin } from 'pinia' import { getNavigation } from '@nextcloud/files' +import { getRequestToken } from '@nextcloud/auth' import FilesListView from './views/FilesList.vue' import NavigationView from './views/Navigation.vue' @@ -34,6 +36,9 @@ declare global { } } +// @ts-expect-error __webpack_nonce__ is injected by webpack +__webpack_nonce__ = btoa(getRequestToken()) + // Init private and public Files namespace window.OCA.Files = window.OCA.Files ?? {} window.OCP.Files = window.OCP.Files ?? {} diff --git a/package-lock.json b/package-lock.json index 123765e5e1009..9820cbda97cbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,9 @@ "@nextcloud/browserslist-config": "^2.3.0", "@nextcloud/calendar-availability-vue": "^2.0.0-beta.1", "@nextcloud/capabilities": "^1.0.4", - "@nextcloud/dialogs": "^4.1.0", + "@nextcloud/dialogs": "^5.0.0-beta.2", "@nextcloud/event-bus": "^3.1.0", - "@nextcloud/files": "^3.0.0-beta.18", + "@nextcloud/files": "^3.0.0-beta.19", "@nextcloud/initial-state": "^2.0.0", "@nextcloud/l10n": "^2.1.0", "@nextcloud/logger": "^2.5.0", @@ -3571,18 +3571,28 @@ } }, "node_modules/@nextcloud/dialogs": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-4.1.0.tgz", - "integrity": "sha512-7e0QMdJKL1Pn/RxOA6Fjm2PMSEUSvhRXuyoZqNFN/rvvVK9mXOCvkRI+vYwuCBCzoTi1Bv3k12BoXxB2UHAufQ==", + "version": "5.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-5.0.0-beta.2.tgz", + "integrity": "sha512-IRf5iOzr0ZJnysdeBd6Q3tNfF7CgHFcsRCKBv/gayLCZQpV/kC4K8dg9ETtGqd/YNkfojqcOrEyRDzOy/sRE/w==", "dependencies": { - "@nextcloud/l10n": "^2.1.0", + "@mdi/svg": "^7.2.96", + "@nextcloud/files": "^3.0.0-beta.16", + "@nextcloud/l10n": "^2.2.0", "@nextcloud/typings": "^1.7.0", - "core-js": "^3.31.0", - "toastify-js": "^1.12.0" + "@nextcloud/vue": "^8.0.0-beta.3", + "@types/toastify-js": "^1.12.0", + "@vueuse/core": "^10.3.0", + "toastify-js": "^1.12.0", + "vue-frag": "^1.4.3", + "vue-material-design-icons": "^5.2.0", + "webdav": "^5.2.3" }, "engines": { "node": "^20.0.0", "npm": "^9.0.0" + }, + "peerDependencies": { + "vue": "^2.7.14" } }, "node_modules/@nextcloud/eslint-config": { @@ -3670,9 +3680,9 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@nextcloud/files": { - "version": "3.0.0-beta.18", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.0.0-beta.18.tgz", - "integrity": "sha512-uu+55g21ps7ZtoVqFNliShtHclWO3p7mHv1Sy0qGjwWmQPu4fKjixgQ9SscdLL+9tXSmDR94qB3XuKe4EZ8hNQ==", + "version": "3.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.0.0-beta.19.tgz", + "integrity": "sha512-4VYTlscjR7f4svcZbSjrZrq+KKi3GYPels8PvyolYqMOBLWC4xjOrp5HFa20D7hnL/2Ynwymz3WeJBMP5YW1AQ==", "dependencies": { "@nextcloud/auth": "^2.1.0", "@nextcloud/l10n": "^2.2.0", @@ -3761,6 +3771,37 @@ "npm": "^7.0.0 || ^8.0.0" } }, + "node_modules/@nextcloud/password-confirmation/node_modules/@nextcloud/dialogs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-4.1.0.tgz", + "integrity": "sha512-7e0QMdJKL1Pn/RxOA6Fjm2PMSEUSvhRXuyoZqNFN/rvvVK9mXOCvkRI+vYwuCBCzoTi1Bv3k12BoXxB2UHAufQ==", + "dependencies": { + "@nextcloud/l10n": "^2.1.0", + "@nextcloud/typings": "^1.7.0", + "core-js": "^3.31.0", + "toastify-js": "^1.12.0" + }, + "engines": { + "node": "^20.0.0", + "npm": "^9.0.0" + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@nextcloud/dialogs/node_modules/@nextcloud/l10n": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-2.2.0.tgz", + "integrity": "sha512-UAM2NJcl/NR46MANSF7Gr7q8/Up672zRyGrxLpN3k4URNmWQM9upkbRME+1K3T29wPrUyOIbQu710ZjvZafqFA==", + "dependencies": { + "@nextcloud/router": "^2.1.2", + "@nextcloud/typings": "^1.7.0", + "dompurify": "^3.0.3", + "escape-html": "^1.0.3", + "node-gettext": "^3.0.0" + }, + "engines": { + "node": "^20.0.0", + "npm": "^9.0.0" + } + }, "node_modules/@nextcloud/password-confirmation/node_modules/@nextcloud/l10n": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-1.6.0.tgz", @@ -3910,9 +3951,9 @@ } }, "node_modules/@nextcloud/vue": { - "version": "8.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.0.0-beta.2.tgz", - "integrity": "sha512-6nIQiX1Om2Lpx4Q7S0NuADZdmMG9D2O0KBoWoOUaa0RKT4KOoFLYgM1wuLNprwWyayLkBUHY7yszxeShMxREgw==", + "version": "8.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.0.0-beta.3.tgz", + "integrity": "sha512-inweNkwmxtQgOjntjre7YUrj7Nt5Srwqv7YrBHChLbYzPk/2fifwfDN/zlEOs5Mr+2XDOBf3j2eWw+Gpu9uTbg==", "dependencies": { "@floating-ui/dom": "^1.1.0", "@nextcloud/auth": "^2.0.0", @@ -3949,8 +3990,8 @@ "striptags": "^3.2.0", "tributejs": "^5.1.3", "unified": "^10.1.2", - "unist-builder": "^3.0.1", - "unist-util-visit": "^4.1.2", + "unist-builder": "^4.0.0", + "unist-util-visit": "^5.0.0", "vue": "^2.7.14", "vue-color": "^2.8.1", "vue-material-design-icons": "^5.1.2", @@ -4252,6 +4293,77 @@ "vue": "2.x" } }, + "node_modules/@nextcloud/vue/node_modules/@nextcloud/dialogs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-4.1.0.tgz", + "integrity": "sha512-7e0QMdJKL1Pn/RxOA6Fjm2PMSEUSvhRXuyoZqNFN/rvvVK9mXOCvkRI+vYwuCBCzoTi1Bv3k12BoXxB2UHAufQ==", + "dependencies": { + "@nextcloud/l10n": "^2.1.0", + "@nextcloud/typings": "^1.7.0", + "core-js": "^3.31.0", + "toastify-js": "^1.12.0" + }, + "engines": { + "node": "^20.0.0", + "npm": "^9.0.0" + } + }, + "node_modules/@nextcloud/vue/node_modules/@types/unist": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.0.tgz", + "integrity": "sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==" + }, + "node_modules/@nextcloud/vue/node_modules/unist-builder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-4.0.0.tgz", + "integrity": "sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@nextcloud/vue/node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@nextcloud/vue/node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@nextcloud/vue/node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/@nextcloud/webpack-vue-config": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@nextcloud/webpack-vue-config/-/webpack-vue-config-6.0.0.tgz", @@ -5456,6 +5568,11 @@ "@types/jest": "*" } }, + "node_modules/@types/toastify-js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.0.tgz", + "integrity": "sha512-fqpDHaKhFukN9KRm24bbH0wozvHmSwjvkaLjBUrWcSfSS4zysIwTYqNLG3XbSNhRlsTNRNLGS23tp/VhPwsfHQ==" + }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -18753,6 +18870,21 @@ "vue": "^2.7.14" } }, + "node_modules/nextcloud-vue-collections/node_modules/@nextcloud/dialogs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-4.1.0.tgz", + "integrity": "sha512-7e0QMdJKL1Pn/RxOA6Fjm2PMSEUSvhRXuyoZqNFN/rvvVK9mXOCvkRI+vYwuCBCzoTi1Bv3k12BoXxB2UHAufQ==", + "dependencies": { + "@nextcloud/l10n": "^2.1.0", + "@nextcloud/typings": "^1.7.0", + "core-js": "^3.31.0", + "toastify-js": "^1.12.0" + }, + "engines": { + "node": "^20.0.0", + "npm": "^9.0.0" + } + }, "node_modules/nextcloud-vue-collections/node_modules/@nextcloud/vue": { "version": "7.12.2", "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-7.12.2.tgz", diff --git a/package.json b/package.json index 64366f3b3733f..0c3f685eff2ed 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,9 @@ "@nextcloud/browserslist-config": "^2.3.0", "@nextcloud/calendar-availability-vue": "^2.0.0-beta.1", "@nextcloud/capabilities": "^1.0.4", - "@nextcloud/dialogs": "^4.1.0", + "@nextcloud/dialogs": "^5.0.0-beta.2", "@nextcloud/event-bus": "^3.1.0", - "@nextcloud/files": "^3.0.0-beta.18", + "@nextcloud/files": "^3.0.0-beta.19", "@nextcloud/initial-state": "^2.0.0", "@nextcloud/l10n": "^2.1.0", "@nextcloud/logger": "^2.5.0",