diff options
author | John Molakvoæ <skjnldsv@users.noreply.github.com> | 2024-04-04 13:56:23 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-04 13:56:23 +0200 |
commit | 32e86052d57775e8e519d9bdd7d78b2921d8e5fa (patch) | |
tree | b517ee2ba17db7fc79f9918f93d07609cdd79b14 /apps | |
parent | 908d7a5fe1c99aa27f3e5b297a8c025adbb7aeaf (diff) | |
parent | aebebde34d5d186aa2b5ae94c7c834f9dea6a2c0 (diff) | |
download | nextcloud-server-32e86052d57775e8e519d9bdd7d78b2921d8e5fa.tar.gz nextcloud-server-32e86052d57775e8e519d9bdd7d78b2921d8e5fa.zip |
Merge pull request #44409 from nextcloud/fix/files-dnd-files
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files/src/components/BreadCrumbs.vue | 124 | ||||
-rw-r--r-- | apps/files/src/components/DragAndDropNotice.vue | 83 | ||||
-rw-r--r-- | apps/files/src/components/FileEntryMixin.ts | 88 | ||||
-rw-r--r-- | apps/files/src/services/DropService.ts | 242 | ||||
-rw-r--r-- | apps/files/src/services/DropServiceUtils.spec.ts | 142 | ||||
-rw-r--r-- | apps/files/src/services/DropServiceUtils.ts | 195 |
6 files changed, 686 insertions, 188 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue index d76ed714f20..89312d8dff3 100644 --- a/apps/files/src/components/BreadCrumbs.vue +++ b/apps/files/src/components/BreadCrumbs.vue @@ -34,7 +34,9 @@ :force-icon-text="true" :title="titleForSection(index, section)" :aria-description="ariaForSection(section)" - @click.native="onClick(section.to)"> + @click.native="onClick(section.to)" + @dragover.native="onDragOver($event, section.dir)" + @drop="onDrop($event, section.dir)"> <template v-if="index === 0" #icon> <NcIconSvgWrapper :size="20" :svg="viewIcon" /> @@ -49,20 +51,25 @@ </template> <script lang="ts"> -import type { Node } from '@nextcloud/files' +import { Permission, type Node } from '@nextcloud/files' -import { translate as t} from '@nextcloud/l10n' import { basename } from 'path' -import homeSvg from '@mdi/svg/svg/home.svg?raw' +import { defineComponent } from 'vue' +import { translate as t} from '@nextcloud/l10n' +import HomeSvg from '@mdi/svg/svg/home.svg?raw' import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import { defineComponent } from 'vue' +import { onDropInternalFiles, dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' +import { showError } from '@nextcloud/dialogs' +import { useDragAndDropStore } from '../store/dragging.ts' import { useFilesStore } from '../store/files.ts' import { usePathsStore } from '../store/paths.ts' +import { useSelectionStore } from '../store/selection.ts' import { useUploaderStore } from '../store/uploader.ts' import filesListWidthMixin from '../mixins/filesListWidth.ts' +import logger from '../logger' export default defineComponent({ name: 'BreadCrumbs', @@ -73,6 +80,10 @@ export default defineComponent({ NcIconSvgWrapper, }, + mixins: [ + filesListWidthMixin, + ], + props: { path: { type: String, @@ -80,18 +91,18 @@ export default defineComponent({ }, }, - mixins: [ - filesListWidthMixin, - ], - setup() { + const draggingStore = useDragAndDropStore() const filesStore = useFilesStore() const pathsStore = usePathsStore() + const selectionStore = useSelectionStore() const uploaderStore = useUploaderStore() return { + draggingStore, filesStore, pathsStore, + selectionStore, uploaderStore, } }, @@ -110,7 +121,7 @@ export default defineComponent({ }, sections() { - return this.dirs.map((dir: string) => { + return this.dirs.map((dir: string, index: number) => { const fileid = this.getFileIdFromPath(dir) const to = { ...this.$route, params: { fileid }, query: { dir } } return { @@ -118,6 +129,8 @@ export default defineComponent({ exact: true, name: this.getDirDisplayName(dir), to, + // disable drop on current directory + disableDrop: index === this.dirs.length - 1, } }) }, @@ -128,13 +141,27 @@ export default defineComponent({ // Hide breadcrumbs if an upload is ongoing shouldShowBreadcrumbs(): boolean { - return this.filesListWidth > 400 && !this.isUploadInProgress + // If we're uploading files, only show the breadcrumbs + // if the files list is greater than 768px wide + if (this.isUploadInProgress) { + return this.filesListWidth > 768 + } + // If we're not uploading, we have enough space from 400px + return this.filesListWidth > 400 }, // used to show the views icon for the first breadcrumb viewIcon(): string { - return this.currentView?.icon ?? homeSvg - } + return this.currentView?.icon ?? HomeSvg + }, + + selectedFiles() { + return this.selectionStore.selected + }, + + draggingFiles() { + return this.draggingStore.dragging + }, }, methods: { @@ -160,6 +187,77 @@ export default defineComponent({ } }, + onDragOver(event: DragEvent, path: string) { + // Cannot drop on the current directory + if (path === this.dirs[this.dirs.length - 1]) { + event.dataTransfer.dropEffect = 'none' + return + } + + // Handle copy/move drag and drop + if (event.ctrlKey) { + event.dataTransfer.dropEffect = 'copy' + } else { + event.dataTransfer.dropEffect = 'move' + } + }, + + async onDrop(event: DragEvent, path: string) { + // skip if native drop like text drag and drop from files names + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { + return + } + + // Do not stop propagation, so the main content + // drop event can be triggered too and clear the + // dragover state on the DragAndDropNotice component. + event.preventDefault() + + // Caching the selection + const selection = this.draggingFiles + const items = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + const canDrop = (folder.permissions & Permission.CREATE) !== 0 + const isCopy = event.ctrlKey + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!canDrop || event.button !== 0) { + return + } + + logger.debug('Dropped', { event, folder, selection, fileTree }) + + // Check whether we're uploading files + if (fileTree.contents.length > 0) { + await onDropExternalFiles(fileTree, folder, contents.contents) + return + } + + // Else we're moving/copying files + const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[] + await onDropInternalFiles(nodes, folder, contents.contents, isCopy) + + // Reset selection after we dropped the files + // if the dropped files are within the selection + if (selection.some(fileid => this.selectedFiles.includes(fileid))) { + logger.debug('Dropped selection, resetting select store...') + this.selectionStore.reset() + } + }, + titleForSection(index, section) { if (section?.to?.query?.dir === this.$route.query.dir) { return t('files', 'Reload current directory') diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue index f9b830ca755..c036c86fb64 100644 --- a/apps/files/src/components/DragAndDropNotice.vue +++ b/apps/files/src/components/DragAndDropNotice.vue @@ -46,14 +46,14 @@ <script lang="ts"> import { defineComponent } from 'vue' import { Folder, Permission } from '@nextcloud/files' -import { showError, showSuccess } from '@nextcloud/dialogs' +import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import { UploadStatus } from '@nextcloud/upload' import TrayArrowDownIcon from 'vue-material-design-icons/TrayArrowDown.vue' import logger from '../logger.js' -import { handleDrop } from '../services/DropService' +import { dataTransferToFileTree, onDropExternalFiles } from '../services/DropService' export default defineComponent({ name: 'DragAndDropNotice', @@ -76,6 +76,10 @@ export default defineComponent({ }, computed: { + currentView() { + return this.$navigation.active + }, + /** * Check if the current folder has create permissions */ @@ -146,8 +150,6 @@ export default defineComponent({ }, async onDrop(event: DragEvent) { - logger.debug('Dropped on DragAndDropNotice', { event }) - // cantUploadLabel is null if we can upload if (this.cantUploadLabel) { showError(this.cantUploadLabel) @@ -161,38 +163,53 @@ export default defineComponent({ event.preventDefault() event.stopPropagation() - if (event.dataTransfer && event.dataTransfer.items.length > 0) { - // Start upload - logger.debug(`Uploading files to ${this.currentFolder.path}`) - // Process finished uploads - const uploads = await handleDrop(event.dataTransfer) - logger.debug('Upload terminated', { uploads }) - - if (uploads.some((upload) => upload.status === UploadStatus.FAILED)) { - showError(t('files', 'Some files could not be uploaded')) - const failedUploads = uploads.filter((upload) => upload.status === UploadStatus.FAILED) - logger.debug('Some files could not be uploaded', { failedUploads }) - } else { - showSuccess(t('files', 'Files uploaded successfully')) - } - - // Scroll to last successful upload in current directory if terminated - const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED - && !upload.file.webkitRelativePath.includes('/') - && upload.response?.headers?.['oc-fileid']) - - if (lastUpload !== undefined) { - this.$router.push({ - ...this.$route, - params: { - view: this.$route.params?.view ?? 'files', - fileid: parseInt(lastUpload.response!.headers['oc-fileid']), - }, - }) - } + // Caching the selection + const items: DataTransferItem[] = [...event.dataTransfer?.items || []] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(this.currentFolder.path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return } + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (event.button) { + return + } + + logger.debug('Dropped', { event, folder, fileTree }) + + // Check whether we're uploading files + const uploads = await onDropExternalFiles(fileTree, folder, contents.contents) + + // Scroll to last successful upload in current directory if terminated + const lastUpload = uploads.findLast((upload) => upload.status !== UploadStatus.FAILED + && !upload.file.webkitRelativePath.includes('/') + && upload.response?.headers?.['oc-fileid'] + // Only use the last ID if it's in the current folder + && upload.source.replace(folder.source, '').split('/').length === 2) + + if (lastUpload !== undefined) { + logger.debug('Scrolling to last upload in current folder', { lastUpload }) + this.$router.push({ + ...this.$route, + params: { + view: this.$route.params?.view ?? 'files', + fileid: parseInt(lastUpload.response!.headers['oc-fileid']), + }, + }) + } + this.dragover = false }, + t, }, }) diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts index e4e05e821e7..b822363885c 100644 --- a/apps/files/src/components/FileEntryMixin.ts +++ b/apps/files/src/components/FileEntryMixin.ts @@ -22,21 +22,19 @@ import type { PropType } from 'vue' -import { extname, join } from 'path' +import { extname } from 'path' import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' -import { showError, showSuccess } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' -import { Upload, getUploader } from '@nextcloud/upload' import { vOnClickOutside } from '@vueuse/components' import Vue, { defineComponent } from 'vue' import { action as sidebarAction } from '../actions/sidebarAction.ts' import { getDragAndDropPreview } from '../utils/dragUtils.ts' -import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts' import { hashCode } from '../utils/hashUtils.ts' -import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts' +import { dataTransferToFileTree, onDropExternalFiles, onDropInternalFiles } from '../services/DropService.ts' import logger from '../logger.js' +import { showError } from '@nextcloud/dialogs' Vue.directive('onClickOutside', vOnClickOutside) @@ -309,79 +307,53 @@ export default defineComponent({ async onDrop(event: DragEvent) { // skip if native drop like text drag and drop from files names - if (!this.draggingFiles && !event.dataTransfer?.files?.length) { + if (!this.draggingFiles && !event.dataTransfer?.items?.length) { return } event.preventDefault() event.stopPropagation() - // If another button is pressed, cancel it - // This allows cancelling the drag with the right click - if (!this.canDrop || event.button !== 0) { + // Caching the selection + const selection = this.draggingFiles + const items = [...event.dataTransfer?.items || []] as DataTransferItem[] + + // We need to process the dataTransfer ASAP before the + // browser clears it. This is why we cache the items too. + const fileTree = await dataTransferToFileTree(items) + + // We might not have the target directory fetched yet + const contents = await this.currentView?.getContents(this.source.path) + const folder = contents?.folder + if (!folder) { + showError(this.t('files', 'Target folder does not exist any more')) + return + } + + // If another button is pressed, cancel it. This + // allows cancelling the drag with the right click. + if (!this.canDrop || event.button) { return } const isCopy = event.ctrlKey this.dragover = false - logger.debug('Dropped', { event, selection: this.draggingFiles }) + logger.debug('Dropped', { event, folder, selection, fileTree }) // Check whether we're uploading files - if (event.dataTransfer?.files - && event.dataTransfer.files.length > 0) { - const uploader = getUploader() - - // Check whether the uploader is in the same folder - // This should never happen™ - if (!uploader.destination.path.startsWith(uploader.destination.path)) { - logger.error('The current uploader destination is not the same as the current folder') - showError(t('files', 'An error occurred while uploading. Please try again later.')) - return - } - - logger.debug(`Uploading files to ${this.source.path}`) - const queue = [] as Promise<Upload>[] - for (const file of event.dataTransfer.files) { - // Because the uploader destination is properly set to the current folder - // we can just use the basename as the relative path. - queue.push(uploader.upload(join(this.source.basename, file.name), file)) - } - - const results = await Promise.allSettled(queue) - const errors = results.filter(result => result.status === 'rejected') - if (errors.length > 0) { - logger.error('Error while uploading files', { errors }) - showError(t('files', 'Some files could not be uploaded')) - return - } - - logger.debug('Files uploaded successfully') - showSuccess(t('files', 'Files uploaded successfully')) + if (fileTree.contents.length > 0) { + await onDropExternalFiles(fileTree, folder, contents.contents) return } - const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[] - nodes.forEach(async (node: Node) => { - Vue.set(node, 'status', NodeStatus.LOADING) - try { - // TODO: resolve potential conflicts prior and force overwrite - await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE) - } catch (error) { - logger.error('Error while moving file', { error }) - if (isCopy) { - showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' })) - } else { - showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' })) - } - } finally { - Vue.set(node, 'status', undefined) - } - }) + // Else we're moving/copying files + const nodes = selection.map(fileid => this.filesStore.getNode(fileid)) as Node[] + await onDropInternalFiles(nodes, folder, contents.contents, isCopy) // Reset selection after we dropped the files // if the dropped files are within the selection - if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) { + if (selection.some(fileid => this.selectedFiles.includes(fileid))) { logger.debug('Dropped selection, resetting select store...') this.selectionStore.reset() } diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts index b3371f44337..d3711741753 100644 --- a/apps/files/src/services/DropService.ts +++ b/apps/files/src/services/DropService.ts @@ -2,6 +2,7 @@ * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> * * @author Ferdinand Thiessen <opensource@fthiessen.de> + * @author John Molakvoæ <skjnldsv@protonmail.com> * * @license AGPL-3.0-or-later * @@ -11,7 +12,7 @@ * 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 + * 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. * @@ -21,123 +22,196 @@ */ import type { Upload } from '@nextcloud/upload' -import type { FileStat, ResponseDataDetailed } from 'webdav' +import type { RootDirectory } from './DropServiceUtils' -import { davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files' -import { emit } from '@nextcloud/event-bus' -import { getUploader } from '@nextcloud/upload' +import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files' +import { getUploader, hasConflict } from '@nextcloud/upload' +import { join } from 'path' import { joinPaths } from '@nextcloud/paths' -import { showError } from '@nextcloud/dialogs' +import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' +import Vue from 'vue' +import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils' +import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction' +import { MoveCopyAction } from '../actions/moveOrCopyActionUtils' import logger from '../logger.js' -export const handleDrop = async (data: DataTransfer): Promise<Upload[]> => { - // TODO: Maybe handle `getAsFileSystemHandle()` in the future - - const uploads = [] as Upload[] - // we need to cache the entries to prevent Blink engine bug that clears the list (`data.items`) after first access props of one of the entries - const entries = [...data.items] +/** + * This function converts a list of DataTransferItems to a file tree. + * It uses the Filesystem API if available, otherwise it falls back to the File API. + * The File API will NOT be available if the browser is not in a secure context (e.g. HTTP). + * ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems + * will be cleared after the first access to the props of one of the entries. + * + * @param items the list of DataTransferItems + */ +export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => { + // Check if the browser supports the Filesystem API + // We need to cache the entries to prevent Blink engine bug that clears + // the list (`data.items`) after first access props of one of the entries + const entries = items .filter((item) => { if (item.kind !== 'file') { logger.debug('Skipping dropped item', { kind: item.kind, type: item.type }) return false } return true - }) - .map((item) => { + }).map((item) => { // MDN recommends to try both, as it might be renamed in the future - return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined})?.getAsEntry?.() ?? item.webkitGetAsEntry() ?? item - }) + return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.() + ?? item?.webkitGetAsEntry?.() + ?? item + }) as (FileSystemEntry | DataTransferItem)[] + + let warned = false + const fileTree = new Directory('root') as RootDirectory + // Traverse the file tree for (const entry of entries) { // Handle browser issues if Filesystem API is not available. Fallback to File API if (entry instanceof DataTransferItem) { - logger.debug('Could not get FilesystemEntry of item, falling back to file') + logger.warn('Could not get FilesystemEntry of item, falling back to file') + const file = entry.getAsFile() if (file === null) { logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind }) showError(t('files', 'One of the dropped files could not be processed')) - } else { - uploads.push(await handleFileUpload(file)) + continue } - } else { - logger.debug('Handle recursive upload', { entry: entry.name }) - // Use Filesystem API - uploads.push(...await handleRecursiveUpload(entry)) + + // Warn the user that the browser does not support the Filesystem API + // we therefore cannot upload directories recursively. + if (file.type === 'httpd/unix-directory' || !file.type) { + if (!warned) { + logger.warn('Browser does not support Filesystem API. Directories will not be uploaded') + showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded')) + warned = true + } + continue + } + + fileTree.contents.push(file) + continue + } + + // Use Filesystem API + try { + fileTree.contents.push(await traverseTree(entry)) + } catch (error) { + // Do not throw, as we want to continue with the other files + logger.error('Error while traversing file tree', { error }) } } - return uploads + + return fileTree } -const handleFileUpload = async (file: File, path: string = '') => { +export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => { const uploader = getUploader() - try { - return await uploader.upload(`${path}${file.name}`, file) - } catch (e) { - showError(t('files', 'Uploading "{filename}" failed', { filename: file.name })) - throw e + // Check for conflicts on root elements + if (await hasConflict(root.contents, contents)) { + root.contents = await resolveConflict(root.contents, destination, contents) } -} -const handleRecursiveUpload = async (entry: FileSystemEntry, path: string = ''): Promise<Upload[]> => { - if (entry.isFile) { - return [ - await new Promise<Upload>((resolve, reject) => { - (entry as FileSystemFileEntry).file( - async (file) => resolve(await handleFileUpload(file, path)), - (error) => reject(error), - ) - }), - ] - } else { - const directory = entry as FileSystemDirectoryEntry - - // TODO: Implement this on `@nextcloud/upload` - const absolutPath = joinPaths(davRootPath, getUploader().destination.path, path, directory.name) - - logger.debug('Handle directory recursively', { name: directory.name, absolutPath }) - - const davClient = davGetClient() - const dirExists = await davClient.exists(absolutPath) - if (!dirExists) { - logger.debug('Directory does not exist, creating it', { absolutPath }) - await davClient.createDirectory(absolutPath, { recursive: true }) - const stat = await davClient.stat(absolutPath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat> - emit('files:node:created', davResultToNode(stat.data)) + if (root.contents.length === 0) { + logger.info('No files to upload', { root }) + showInfo(t('files', 'No files to upload')) + return [] + } + + // Let's process the files + logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents }) + const queue = [] as Promise<Upload>[] + + const uploadDirectoryContents = async (directory: Directory, path: string) => { + for (const file of directory.contents) { + // This is the relative path to the resource + // from the current uploader destination + const relativePath = join(path, file.name) + + // If the file is a directory, we need to create it first + // then browse its tree and upload its contents. + if (file instanceof Directory) { + const absolutePath = joinPaths(davRootPath, destination.path, relativePath) + try { + console.debug('Processing directory', { relativePath }) + await createDirectoryIfNotExists(absolutePath) + await uploadDirectoryContents(file, relativePath) + } catch (error) { + showError(t('files', 'Unable to create the directory {directory}', { directory: file.name })) + logger.error('', { error, absolutePath, directory: file }) + } + continue + } + + // If we've reached a file, we can upload it + logger.debug('Uploading file to ' + join(destination.path, relativePath), { file }) + + // Overriding the root to avoid changing the current uploader context + queue.push(uploader.upload(relativePath, file, destination.source)) } + } + + // Pause the uploader to prevent it from starting + // while we compute the queue + uploader.pause() + + // Upload the files. Using '/' as the starting point + // as we already adjusted the uploader destination + await uploadDirectoryContents(root, '/') + uploader.start() - const entries = await readDirectory(directory) - // sorted so we upload files first before starting next level - const promises = entries.sort((a) => a.isFile ? -1 : 1) - .map((file) => handleRecursiveUpload(file, `${path}${directory.name}/`)) - return (await Promise.all(promises)).flat() + // Wait for all promises to settle + const results = await Promise.allSettled(queue) + + // Check for errors + const errors = results.filter(result => result.status === 'rejected') + if (errors.length > 0) { + logger.error('Error while uploading files', { errors }) + showError(t('files', 'Some files could not be uploaded')) + return [] } + + logger.debug('Files uploaded successfully') + showSuccess(t('files', 'Files uploaded successfully')) + + return Promise.all(queue) } -/** - * Read a directory using Filesystem API - * @param directory the directory to read - */ -function readDirectory(directory: FileSystemDirectoryEntry) { - const dirReader = directory.createReader() - - return new Promise<FileSystemEntry[]>((resolve, reject) => { - const entries = [] as FileSystemEntry[] - const getEntries = () => { - dirReader.readEntries((results) => { - if (results.length) { - entries.push(...results) - getEntries() - } else { - resolve(entries) - } - }, (error) => { - reject(error) - }) - } +export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => { + const queue = [] as Promise<void>[] + + // Check for conflicts on root elements + if (await hasConflict(nodes, contents)) { + nodes = await resolveConflict(nodes, destination, contents) + } + + if (nodes.length === 0) { + logger.info('No files to process', { nodes }) + showInfo(t('files', 'No files to process')) + return + } + + for (const node of nodes) { + Vue.set(node, 'status', NodeStatus.LOADING) + // TODO: resolve potential conflicts prior and force overwrite + queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)) + } + + // Wait for all promises to settle + const results = await Promise.allSettled(queue) + nodes.forEach(node => Vue.set(node, 'status', undefined)) + + // Check for errors + const errors = results.filter(result => result.status === 'rejected') + if (errors.length > 0) { + logger.error('Error while copying or moving files', { errors }) + showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved')) + return + } - getEntries() - }) + logger.debug('Files copy/move successful') + showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully')) } diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts new file mode 100644 index 00000000000..1502d83d9ce --- /dev/null +++ b/apps/files/src/services/DropServiceUtils.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from '@jest/globals' + +import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils' +import { join } from 'node:path' +import { Directory, traverseTree } from './DropServiceUtils' +import { dataTransferToFileTree } from './DropService' +import logger from '../logger' + +const dataTree = { + 'file0.txt': ['Hello, world!', 1234567890], + dir1: { + 'file1.txt': ['Hello, world!', 4567891230], + 'file2.txt': ['Hello, world!', 7891234560], + }, + dir2: { + 'file3.txt': ['Hello, world!', 1234567890], + }, +} + +// This is mocking a file tree using the FileSystem API +const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => { + const entries = Object.entries(tree).map(([name, contents]) => { + const fullPath = join(path, name) + if (Array.isArray(contents)) { + return new FileSystemFileEntry(fullPath, contents[0], contents[1]) + } else { + return buildFileSystemDirectoryEntry(fullPath, contents) + } + }) + return new FileSystemDirectoryEntry(path, entries) +} + +const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => { + return Object.entries(tree).map(([name, contents]) => { + const fullPath = join(path, name) + if (Array.isArray(contents)) { + const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1]) + return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable) + } + + const entry = buildFileSystemDirectoryEntry(fullPath, contents) + return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable) + }) +} + +describe('Filesystem API traverseTree', () => { + it('Should traverse a file tree from root', async () => { + // Fake a FileSystemEntry tree + const root = buildFileSystemDirectoryEntry('root', dataTree) + const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory + + expect(tree.name).toBe('root') + expect(tree).toBeInstanceOf(Directory) + expect(tree.contents).toHaveLength(3) + expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!' + }) + + it('Should traverse a file tree from a subdirectory', async () => { + // Fake a FileSystemEntry tree + const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2) + const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory + + expect(tree.name).toBe('dir2') + expect(tree).toBeInstanceOf(Directory) + expect(tree.contents).toHaveLength(1) + expect(tree.contents[0].name).toBe('file3.txt') + expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!' + }) + + it('Should properly compute the last modified', async () => { + // Fake a FileSystemEntry tree + const root = buildFileSystemDirectoryEntry('root', dataTree) + const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory + + expect(rootTree.lastModified).toBe(7891234560) + + // Fake a FileSystemEntry tree + const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2) + const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory + expect(dir2Tree.lastModified).toBe(1234567890) + }) +}) + +describe('DropService dataTransferToFileTree', () => { + + beforeAll(() => { + // DataTransferItem doesn't exists in jsdom, let's mock + // a dumb one so we can check the instanceof + // @ts-expect-error jsdom doesn't have DataTransferItem + window.DataTransferItem = DataTransferItemMock + }) + + afterAll(() => { + // @ts-expect-error jsdom doesn't have DataTransferItem + delete window.DataTransferItem + }) + + it('Should return a RootDirectory with Filesystem API', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => jest.fn()) + jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn()) + + const dataTransferItems = buildDataTransferItemArray('root', dataTree) + const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) + + expect(fileTree.name).toBe('root') + expect(fileTree).toBeInstanceOf(Directory) + expect(fileTree.contents).toHaveLength(3) + + // The file tree should be recursive when using the Filesystem API + expect(fileTree.contents[1]).toBeInstanceOf(Directory) + expect((fileTree.contents[1] as Directory).contents).toHaveLength(2) + expect(fileTree.contents[2]).toBeInstanceOf(Directory) + expect((fileTree.contents[2] as Directory).contents).toHaveLength(1) + + expect(logger.error).not.toBeCalled() + expect(logger.warn).not.toBeCalled() + }) + + it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => jest.fn()) + jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn()) + + const dataTransferItems = buildDataTransferItemArray('root', dataTree, false) + + const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) + + expect(fileTree.name).toBe('root') + expect(fileTree).toBeInstanceOf(Directory) + expect(fileTree.contents).toHaveLength(1) + + // The file tree should be recursive when using the Filesystem API + expect(fileTree.contents[0]).not.toBeInstanceOf(Directory) + expect((fileTree.contents[0].name)).toBe('file0.txt') + + expect(logger.error).not.toBeCalled() + expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded') + expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file') + expect(logger.warn).toHaveBeenCalledTimes(4) + }) +}) diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts new file mode 100644 index 00000000000..6fd051f9dae --- /dev/null +++ b/apps/files/src/services/DropServiceUtils.ts @@ -0,0 +1,195 @@ +/** + * @copyright Copyright (c) 2024 John Molakvoæ <skjnldsv@protonmail.com> + * + * @author John Molakvoæ <skjnldsv@protonmail.com> + * + * @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 <http://www.gnu.org/licenses/>. + * + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' + +import { emit } from '@nextcloud/event-bus' +import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files' +import { openConflictPicker } from '@nextcloud/upload' +import { showError, showInfo } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +import logger from '../logger.js' + +/** + * This represents a Directory in the file tree + * We extend the File class to better handling uploading + * and stay as close as possible as the Filesystem API. + * This also allow us to hijack the size or lastModified + * properties to compute them dynamically. + */ +export class Directory extends File { + + /* eslint-disable no-use-before-define */ + _contents: (Directory|File)[] + + constructor(name, contents: (Directory|File)[] = []) { + super([], name, { type: 'httpd/unix-directory' }) + this._contents = contents + } + + set contents(contents: (Directory|File)[]) { + this._contents = contents + } + + get contents(): (Directory|File)[] { + return this._contents + } + + get size() { + return this._computeDirectorySize(this) + } + + get lastModified() { + if (this._contents.length === 0) { + return Date.now() + } + return this._computeDirectoryMtime(this) + } + + /** + * Get the last modification time of a file tree + * This is not perfect, but will get us a pretty good approximation + * @param directory the directory to traverse + */ + _computeDirectoryMtime(directory: Directory): number { + return directory.contents.reduce((acc, file) => { + return file.lastModified > acc + // If the file is a directory, the lastModified will + // also return the results of its _computeDirectoryMtime method + // Fancy recursion, huh? + ? file.lastModified + : acc + }, 0) + } + + /** + * Get the size of a file tree + * @param directory the directory to traverse + */ + _computeDirectorySize(directory: Directory): number { + return directory.contents.reduce((acc: number, entry: Directory|File) => { + // If the file is a directory, the size will + // also return the results of its _computeDirectorySize method + // Fancy recursion, huh? + return acc + entry.size + }, 0) + } + +} + +export type RootDirectory = Directory & { + name: 'root' +} + +/** + * Traverse a file tree using the Filesystem API + * @param entry the entry to traverse + */ +export const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => { + // Handle file + if (entry.isFile) { + return new Promise<File>((resolve, reject) => { + (entry as FileSystemFileEntry).file(resolve, reject) + }) + } + + // Handle directory + logger.debug('Handling recursive file tree', { entry: entry.name }) + const directory = entry as FileSystemDirectoryEntry + const entries = await readDirectory(directory) + const contents = (await Promise.all(entries.map(traverseTree))).flat() + return new Directory(directory.name, contents) +} + +/** + * Read a directory using Filesystem API + * @param directory the directory to read + */ +const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => { + const dirReader = directory.createReader() + + return new Promise<FileSystemEntry[]>((resolve, reject) => { + const entries = [] as FileSystemEntry[] + const getEntries = () => { + dirReader.readEntries((results) => { + if (results.length) { + entries.push(...results) + getEntries() + } else { + resolve(entries) + } + }, (error) => { + reject(error) + }) + } + + getEntries() + }) +} + +export const createDirectoryIfNotExists = async (absolutePath: string) => { + const davClient = davGetClient() + const dirExists = await davClient.exists(absolutePath) + if (!dirExists) { + logger.debug('Directory does not exist, creating it', { absolutePath }) + await davClient.createDirectory(absolutePath, { recursive: true }) + const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat> + emit('files:node:created', davResultToNode(stat.data)) + } +} + +export const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => { + try { + // List all conflicting files + const conflicts = files.filter((file: File|Node) => { + return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename)) + }).filter(Boolean) as (File|Node)[] + + // List of incoming files that are NOT in conflict + const uploads = files.filter((file: File|Node) => { + return !conflicts.includes(file) + }) + + // Let the user choose what to do with the conflicting files + const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents) + + logger.debug('Conflict resolution', { uploads, selected, renamed }) + + // If the user selected nothing, we cancel the upload + if (selected.length === 0 && renamed.length === 0) { + // User skipped + showInfo(t('files', 'Conflicts resolution skipped')) + logger.info('User skipped the conflict resolution') + return [] + } + + // Update the list of files to upload + return [...uploads, ...selected, ...renamed] as (typeof files) + } catch (error) { + console.error(error) + // User cancelled + showError(t('files', 'Upload cancelled')) + logger.error('User cancelled the upload') + } + + return [] +} |