From d2673c519a6e54793b8a58c6d62411e1dce1a153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= Date: Thu, 24 Aug 2023 12:16:53 +0200 Subject: [PATCH] feat(files): add move or copy action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ --- apps/files/src/actions/moveOrCopyAction.ts | 248 +++++++++++++++++ .../src/actions/moveOrCopyActionUtils.ts | 73 +++++ .../src/components/DragAndDropPreview.vue | 181 +++++++++++++ apps/files/src/components/FileEntry.vue | 249 +++++++++--------- .../src/components/FilesListHeaderActions.vue | 7 +- .../FilesListTableHeaderActions.vue | 8 +- .../files/src/components/FilesListVirtual.vue | 11 +- apps/files/src/components/TemplatePreview.vue | 2 +- apps/files/src/init.ts | 2 + apps/files/src/store/files.ts | 7 +- apps/files/src/store/paths.ts | 4 +- apps/files/src/store/selection.ts | 2 +- apps/files/src/types.ts | 1 - apps/files/src/utils/dragUtils.ts | 42 +++ .../src/utils/{fileUtils.js => fileUtils.ts} | 32 ++- apps/files/src/views/FilesList.vue | 5 +- apps/files_versions/src/utils/versions.js | 2 +- 17 files changed, 732 insertions(+), 144 deletions(-) create mode 100644 apps/files/src/actions/moveOrCopyAction.ts create mode 100644 apps/files/src/actions/moveOrCopyActionUtils.ts create mode 100644 apps/files/src/components/DragAndDropPreview.vue create mode 100644 apps/files/src/utils/dragUtils.ts rename apps/files/src/utils/{fileUtils.js => fileUtils.ts} (54%) diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts new file mode 100644 index 0000000000000..3abd64bde34ae --- /dev/null +++ b/apps/files/src/actions/moveOrCopyAction.ts @@ -0,0 +1,248 @@ +/** + * @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 type { Folder, Node, View } from '@nextcloud/files' +import type { IFilePickerButton } from '@nextcloud/dialogs' + +// eslint-disable-next-line n/no-extraneous-import +import { AxiosError } from 'axios' +import { basename } from 'path' +import { emit } from '@nextcloud/event-bus' +import { generateRemoteUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { getFilePickerBuilder, showError } from '@nextcloud/dialogs' +import { Permission, FileAction, FileType, NodeStatus } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import Vue from 'vue' + +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 { MoveCopyAction, canCopy, canDownload, canMove, getQueue } from './moveOrCopyActionUtils' +import logger from '../logger' + +/** + * 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 +} + +/** + * Handle the copy/move of a node to a destination + * This can be imported and used by other scripts/components on server + * @param {Node} node The node to copy/move + * @param {Folder} destination The destination to copy/move the node to + * @param {MoveCopyAction} method The method to use for the copy/move + * @param {boolean} overwrite Whether to overwrite the destination if it exists + * @return {Promise} A promise that resolves when the copy/move is done + */ +export const handleCopyMoveNodeTo = async (node: Node, destination: Folder, method: MoveCopyAction.COPY | MoveCopyAction.MOVE, overwrite = false) => { + 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}`) + + // Set loading state + Vue.set(node, 'status', NodeStatus.LOADING) + + const queue = getQueue() + return await queue.add(async () => { + try { + await axios({ + method: method === MoveCopyAction.COPY ? 'COPY' : 'MOVE', + url: encodeURI(node.source), + headers: { + Destination: encodeURI(destinationUrl), + Overwrite: overwrite ? undefined : 'F', + }, + }) + + // If we're moving, update the node + // if we're copying, we don't need to update the node + // the view will refresh itself + if (method === MoveCopyAction.MOVE) { + // Delete the node as it will be fetched again + // when navigating to the destination folder + emit('files:node:deleted', node) + } + } 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?.response?.status === 423) { + throw new Error(t('files', 'The files is locked')) + } else if (error?.response?.status === 404) { + throw new Error(t('files', 'The file does not exist anymore')) + } else if (error.message) { + throw new Error(error.message) + } + } + throw new Error() + } finally { + Vue.set(node, 'status', undefined) + } + }) +} + +/** + * 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().catch(() => { + reject(new Error(t('files', 'Cancelled move or copy operation'))) + }) + }) +} + +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, +}) diff --git a/apps/files/src/actions/moveOrCopyActionUtils.ts b/apps/files/src/actions/moveOrCopyActionUtils.ts new file mode 100644 index 0000000000000..bbdfca3b91d4e --- /dev/null +++ b/apps/files/src/actions/moveOrCopyActionUtils.ts @@ -0,0 +1,73 @@ +/** + * @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 type { Node } from '@nextcloud/files' +import { Permission } from '@nextcloud/files' +import { generateOcsUrl } from '@nextcloud/router' +import PQueue from 'p-queue' +import axios from '@nextcloud/axios' + +// This is the processing queue. We only want to allow 3 concurrent requests +let queue: PQueue + +/** + * Get the processing queue + */ +export const getQueue = () => { + if (!queue) { + queue = new PQueue({ concurrency: 3 }) + } + return queue +} + +type ShareAttribute = { + enabled: boolean + key: string + scope: string +} + +export enum MoveCopyAction { + MOVE = 'Move', + COPY = 'Copy', + MOVE_OR_COPY = 'move-or-copy', +} + +export const canMove = (nodes: Node[]) => { + const minPermission = nodes.reduce((min, node) => Math.min(min, node.permissions), Permission.ALL) + return (minPermission & Permission.UPDATE) !== 0 +} + +export 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')) + + }) +} + +export 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) +} diff --git a/apps/files/src/components/DragAndDropPreview.vue b/apps/files/src/components/DragAndDropPreview.vue new file mode 100644 index 0000000000000..16d3fcebe8bb7 --- /dev/null +++ b/apps/files/src/components/DragAndDropPreview.vue @@ -0,0 +1,181 @@ + + + + + + diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index ede7f1fad2baf..35d3dc3fc0328 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -21,22 +21,27 @@ --> - + :src="previewUrl" + @error="backgroundFailed = true"> @@ -123,7 +129,7 @@ ref="actionsMenu" :boundaries-element="getBoundariesElement()" :container="getBoundariesElement()" - :disabled="source._loading" + :disabled="isLoading" :force-name="true" :force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */" :inline="enabledInlineActions.length" @@ -178,17 +184,14 @@ diff --git a/apps/files_versions/src/utils/versions.js b/apps/files_versions/src/utils/versions.js index ee49a369d2126..98df139a87f8b 100644 --- a/apps/files_versions/src/utils/versions.js +++ b/apps/files_versions/src/utils/versions.js @@ -24,7 +24,7 @@ import { joinPaths } from '@nextcloud/paths' import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import moment from '@nextcloud/moment' -import { encodeFilePath } from '../../../files/src/utils/fileUtils.js' +import { encodeFilePath } from '../../../files/src/utils/fileUtils.ts' import client from '../utils/davClient.js' import davRequest from '../utils/davRequest.js'