aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/actions/moveOrCopyAction.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/actions/moveOrCopyAction.ts')
-rw-r--r--apps/files/src/actions/moveOrCopyAction.ts373
1 files changed, 373 insertions, 0 deletions
diff --git a/apps/files/src/actions/moveOrCopyAction.ts b/apps/files/src/actions/moveOrCopyAction.ts
new file mode 100644
index 00000000000..06e32c98090
--- /dev/null
+++ b/apps/files/src/actions/moveOrCopyAction.ts
@@ -0,0 +1,373 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Folder, Node, View } from '@nextcloud/files'
+import type { IFilePickerButton } from '@nextcloud/dialogs'
+import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
+import type { MoveCopyResult } from './moveOrCopyActionUtils'
+
+import { isAxiosError } from '@nextcloud/axios'
+import { FilePickerClosed, getFilePickerBuilder, showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
+import { FileAction, FileType, NodeStatus, davGetClient, davRootPath, davResultToNode, davGetDefaultPropfind, getUniqueName, Permission } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { openConflictPicker, hasConflict } from '@nextcloud/upload'
+import { basename, join } from 'path'
+import Vue from 'vue'
+
+import CopyIconSvg from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
+import FolderMoveSvg from '@mdi/svg/svg/folder-move-outline.svg?raw'
+
+import { MoveCopyAction, canCopy, canMove, getQueue } from './moveOrCopyActionUtils'
+import { getContents } from '../services/Files'
+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 (canCopy(nodes)) {
+ return MoveCopyAction.MOVE_OR_COPY
+ }
+ return MoveCopyAction.MOVE
+ }
+
+ // Assuming we can copy as the enabled checks for copy permissions
+ return MoveCopyAction.COPY
+}
+
+/**
+ * Create a loading notification toast
+ * @param mode The move or copy mode
+ * @param source Name of the node that is copied / moved
+ * @param destination Destination path
+ * @return {() => void} Function to hide the notification
+ */
+function createLoadingNotification(mode: MoveCopyAction, source: string, destination: string): () => void {
+ const text = mode === MoveCopyAction.MOVE ? t('files', 'Moving "{source}" to "{destination}" …', { source, destination }) : t('files', 'Copying "{source}" to "{destination}" …', { source, destination })
+
+ let toast: ReturnType<typeof showInfo>|undefined
+ toast = showInfo(
+ `<span class="icon icon-loading-small toast-loading-icon"></span> ${text}`,
+ {
+ isHTML: true,
+ timeout: TOAST_PERMANENT_TIMEOUT,
+ onRemove: () => { toast?.hideToast(); toast = undefined },
+ },
+ )
+ return () => toast && toast.hideToast()
+}
+
+/**
+ * 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<void>} 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'))
+ }
+
+ // Do not allow to MOVE a node to the same folder it is already located
+ if (method === MoveCopyAction.MOVE && node.dirname === destination.path) {
+ throw new Error(t('files', 'This file/folder is already in that directory'))
+ }
+
+ /**
+ * Example:
+ * - node: /foo/bar/file.txt -> path = /foo/bar/file.txt, destination: /foo
+ * Allow move of /foo does not start with /foo/bar/file.txt so allow
+ * - node: /foo , destination: /foo/bar
+ * Do not allow as it would copy foo within itself
+ * - node: /foo/bar.txt, destination: /foo
+ * Allow copy a file to the same directory
+ * - node: "/foo/bar", destination: "/foo/bar 1"
+ * Allow to move or copy but we need to check with trailing / otherwise it would report false positive
+ */
+ if (`${destination.path}/`.startsWith(`${node.path}/`)) {
+ throw new Error(t('files', 'You cannot move a file/folder onto itself or into a subfolder of itself'))
+ }
+
+ // Set loading state
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ const actionFinished = createLoadingNotification(method, node.basename, destination.path)
+
+ const queue = getQueue()
+ return await queue.add(async () => {
+ const copySuffix = (index: number) => {
+ if (index === 1) {
+ return t('files', '(copy)') // TRANSLATORS: Mark a file as a copy of another file
+ }
+ return t('files', '(copy %n)', undefined, index) // TRANSLATORS: Meaning it is the n'th copy of a file
+ }
+
+ try {
+ const client = davGetClient()
+ const currentPath = join(davRootPath, node.path)
+ const destinationPath = join(davRootPath, destination.path)
+
+ if (method === MoveCopyAction.COPY) {
+ let target = node.basename
+ // If we do not allow overwriting then find an unique name
+ if (!overwrite) {
+ const otherNodes = await client.getDirectoryContents(destinationPath) as FileStat[]
+ target = getUniqueName(
+ node.basename,
+ otherNodes.map((n) => n.basename),
+ {
+ suffix: copySuffix,
+ ignoreFileExtension: node.type === FileType.Folder,
+ },
+ )
+ }
+ await client.copyFile(currentPath, join(destinationPath, target))
+ // If the node is copied into current directory the view needs to be updated
+ if (node.dirname === destination.path) {
+ const { data } = await client.stat(
+ join(destinationPath, target),
+ {
+ details: true,
+ data: davGetDefaultPropfind(),
+ },
+ ) as ResponseDataDetailed<FileStat>
+ emit('files:node:created', davResultToNode(data))
+ }
+ } else {
+ // show conflict file popup if we do not allow overwriting
+ if (!overwrite) {
+ const otherNodes = await getContents(destination.path)
+ if (hasConflict([node], otherNodes.contents)) {
+ try {
+ // Let the user choose what to do with the conflicting files
+ const { selected, renamed } = await openConflictPicker(destination.path, [node], otherNodes.contents)
+ // two empty arrays: either only old files or conflict skipped -> no action required
+ if (!selected.length && !renamed.length) {
+ return
+ }
+ } catch (error) {
+ // User cancelled
+ return
+ }
+ }
+ }
+ // getting here means either no conflict, file was renamed to keep both files
+ // in a conflict, or the selected file was chosen to be kept during the conflict
+ try {
+ await client.moveFile(currentPath, join(destinationPath, node.basename))
+ } catch (error) {
+ const parser = new DOMParser()
+ const text = await (error as WebDAVClientError).response?.text()
+ const message = parser.parseFromString(text ?? '', 'text/xml')
+ .querySelector('message')?.textContent
+ if (message) {
+ showError(message)
+ }
+ throw error
+ }
+ // Delete the node as it will be fetched again
+ // when navigating to the destination folder
+ emit('files:node:deleted', node)
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ 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 are 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)
+ }
+ }
+ logger.debug(error as Error)
+ throw new Error()
+ } finally {
+ Vue.set(node, 'status', '')
+ actionFinished()
+ }
+ })
+}
+
+/**
+ * Open a file picker for the given action
+ * @param action The action to open the file picker for
+ * @param dir The directory to start the file picker in
+ * @param nodes The nodes to move/copy
+ * @return The picked destination or false if cancelled by user
+ */
+async function openFilePickerForAction(
+ action: MoveCopyAction,
+ dir = '/',
+ nodes: Node[],
+): Promise<MoveCopyResult | false> {
+ const { resolve, reject, promise } = Promise.withResolvers<MoveCopyResult | false>()
+ const fileIDs = nodes.map(node => node.fileid).filter(Boolean)
+ const filePicker = getFilePickerBuilder(t('files', 'Choose destination'))
+ .allowDirectories(true)
+ .setFilter((n: Node) => {
+ // We don't want to show the current nodes in the file picker
+ return !fileIDs.includes(n.fileid)
+ })
+ .setMimeTypeFilter([])
+ .setMultiSelect(false)
+ .startAt(dir)
+ .setButtonFactory((selection: Node[], path: string) => {
+ const buttons: IFilePickerButton[] = []
+ const target = basename(path)
+
+ const dirnames = nodes.map(node => node.dirname)
+ const paths = nodes.map(node => node.path)
+
+ if (action === MoveCopyAction.COPY || action === MoveCopyAction.MOVE_OR_COPY) {
+ buttons.push({
+ label: target ? t('files', 'Copy to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Copy'),
+ type: 'primary',
+ icon: CopyIconSvg,
+ disabled: selection.some((node) => (node.permissions & Permission.CREATE) === 0),
+ async callback(destination: Node[]) {
+ resolve({
+ destination: destination[0] as Folder,
+ action: MoveCopyAction.COPY,
+ } as MoveCopyResult)
+ },
+ })
+ }
+
+ // Invalid MOVE targets (but valid copy targets)
+ if (dirnames.includes(path)) {
+ // This file/folder is already in that directory
+ return buttons
+ }
+
+ if (paths.includes(path)) {
+ // You cannot move a file/folder onto itself
+ return buttons
+ }
+
+ if (selection.some((node) => (node.permissions & Permission.CREATE) === 0)) {
+ // Missing 'CREATE' permissions for selected destination
+ return buttons
+ }
+
+ if (action === MoveCopyAction.MOVE || action === MoveCopyAction.MOVE_OR_COPY) {
+ buttons.push({
+ label: target ? t('files', 'Move to {target}', { target }, undefined, { escape: false, sanitize: false }) : t('files', 'Move'),
+ type: action === MoveCopyAction.MOVE ? 'primary' : 'secondary',
+ icon: FolderMoveSvg,
+ async callback(destination: Node[]) {
+ resolve({
+ destination: destination[0] as Folder,
+ action: MoveCopyAction.MOVE,
+ } as MoveCopyResult)
+ },
+ })
+ }
+
+ return buttons
+ })
+ .build()
+
+ filePicker.pick()
+ .catch((error: Error) => {
+ logger.debug(error as Error)
+ if (error instanceof FilePickerClosed) {
+ resolve(false)
+ } else {
+ reject(new Error(t('files', 'Move or copy operation failed')))
+ }
+ })
+
+ return promise
+}
+
+export const ACTION_COPY_MOVE = 'move-copy'
+export const action = new FileAction({
+ id: ACTION_COPY_MOVE,
+ 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[], view: View) {
+ // We can not copy or move in single file shares
+ if (view.id === 'public-file-share') {
+ return false
+ }
+ // 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])
+ let result
+ try {
+ result = await openFilePickerForAction(action, dir, [node])
+ } catch (e) {
+ logger.error(e as Error)
+ return false
+ }
+ if (result === false) {
+ return null
+ }
+
+ try {
+ await handleCopyMoveNodeTo(node, result.destination, result.action)
+ return true
+ } catch (error) {
+ if (error instanceof Error && !!error.message) {
+ showError(error.message)
+ // Silent action as we handle the toast
+ return null
+ }
+ return false
+ }
+ },
+
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ const action = getActionForNodes(nodes)
+ const result = await openFilePickerForAction(action, dir, nodes)
+ // Handle cancellation silently
+ if (result === false) {
+ return nodes.map(() => null)
+ }
+
+ const promises = nodes.map(async node => {
+ try {
+ await handleCopyMoveNodeTo(node, result.destination, result.action)
+ return true
+ } catch (error) {
+ logger.error(`Failed to ${result.action} node`, { node, error })
+ return false
+ }
+ })
+
+ // We need to keep the selection on error!
+ // So we do not return null, and for batch action
+ // we let the front handle the error.
+ return await Promise.all(promises)
+ },
+
+ order: 15,
+})