]> source.dussan.org Git - nextcloud-server.git/commitdiff
chore: add drag and drop recursion and FilesystemAPI testing
authorskjnldsv <skjnldsv@protonmail.com>
Tue, 2 Apr 2024 09:28:59 +0000 (11:28 +0200)
committerbackportbot[bot] <backportbot[bot]@users.noreply.github.com>
Thu, 4 Apr 2024 11:58:34 +0000 (11:58 +0000)
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
__tests__/FileSystemAPIUtils.ts [new file with mode: 0644]
apps/files/src/components/DragAndDropNotice.vue
apps/files/src/components/FileEntryMixin.ts
apps/files/src/services/DropService.ts
apps/files/src/services/DropServiceUtils.spec.ts [new file with mode: 0644]
apps/files/src/services/DropServiceUtils.ts [new file with mode: 0644]
cypress/e2e/files/FileSystemAPIUtils.ts [deleted file]
cypress/e2e/files/drag-n-drop.cy.ts
jest.config.ts
package.json

diff --git a/__tests__/FileSystemAPIUtils.ts b/__tests__/FileSystemAPIUtils.ts
new file mode 100644 (file)
index 0000000..efb273e
--- /dev/null
@@ -0,0 +1,124 @@
+import { basename } from 'node:path'
+import mime from 'mime'
+
+class FileSystemEntry {
+
+       private _isFile: boolean
+       private _fullPath: string
+
+       constructor(isFile: boolean, fullPath: string) {
+               this._isFile = isFile
+               this._fullPath = fullPath
+       }
+
+       get isFile() {
+               return !!this._isFile
+       }
+
+       get isDirectory() {
+               return !this.isFile
+       }
+
+       get name() {
+               return basename(this._fullPath)
+       }
+
+}
+
+export class FileSystemFileEntry extends FileSystemEntry {
+
+       private _contents: string
+       private _lastModified: number
+
+       constructor(fullPath: string, contents: string, lastModified = Date.now()) {
+               super(true, fullPath)
+               this._contents = contents
+               this._lastModified = lastModified
+       }
+
+       file(success: (file: File) => void) {
+               const lastModified = this._lastModified
+               // Faking the mime by using the file extension
+               const type = mime.getType(this.name) || ''
+               success(new File([this._contents], this.name, { lastModified, type }))
+       }
+
+}
+
+export class FileSystemDirectoryEntry extends FileSystemEntry {
+
+       private _entries: FileSystemEntry[]
+
+       constructor(fullPath: string, entries: FileSystemEntry[]) {
+               super(false, fullPath)
+               this._entries = entries || []
+       }
+
+       createReader() {
+               let read = false
+               return {
+                       readEntries: (success: (entries: FileSystemEntry[]) => void) => {
+                               if (read) {
+                                       return success([])
+                               }
+                               read = true
+                               success(this._entries)
+                       },
+               }
+       }
+
+}
+
+/**
+ * This mocks the File API's File class
+ * It will allow us to test the Filesystem API as well as the
+ * File API in the same test suite.
+ */
+export class DataTransferItem {
+
+       private _type: string
+       private _entry: FileSystemEntry
+
+       getAsEntry?: () => FileSystemEntry
+
+       constructor(type = '', entry: FileSystemEntry, isFileSystemAPIAvailable = true) {
+               this._type = type
+               this._entry = entry
+
+               // Only when the Files API is available we are
+               // able to get the entry
+               if (isFileSystemAPIAvailable) {
+                       this.getAsEntry = () => this._entry
+               }
+       }
+
+       get kind() {
+               return 'file'
+       }
+
+       get type() {
+               return this._type
+       }
+
+       getAsFile(): File|null {
+               if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) {
+                       let file: File | null = null
+                       this._entry.file((f) => {
+                               file = f
+                       })
+                       return file
+               }
+
+               // The browser will return an empty File object if the entry is a directory
+               return new File([], this._entry.name, { type: '' })
+       }
+
+}
+
+export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => {
+       return new DataTransferItem(
+               entry.isFile ? 'text/plain' : 'httpd/unix-directory',
+               entry,
+               isFileSystemAPIAvailable,
+       )
+}
index d591f6ee934da111a3e26656a8878ea6d4061ea3..c036c86fb64a7beae4ac97c15817bb2470576c4f 100644 (file)
@@ -180,7 +180,7 @@ export default defineComponent({
 
                        // If another button is pressed, cancel it. This
                        // allows cancelling the drag with the right click.
-                       if (event.button !== 0) {
+                       if (event.button) {
                                return
                        }
 
@@ -192,9 +192,12 @@ export default defineComponent({
                        // 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'])
+                               && 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: {
index 9eead369322b9a1000508bdee77268c95e91b0c7..d3a9e80c9b7b555ffce0ac8d7a4a78766b9a7c43 100644 (file)
@@ -332,7 +332,7 @@ export default defineComponent({
 
                        // If another button is pressed, cancel it. This
                        // allows cancelling the drag with the right click.
-                       if (!this.canDrop || event.button !== 0) {
+                       if (!this.canDrop || event.button) {
                                return
                        }
 
index e5f806d9f0b9d3e4867cf0e4bdea2b710eace82a..d37117417537aac60402258793b7e2dc7dff5a72 100644 (file)
  */
 
 import type { Upload } from '@nextcloud/upload'
-import type { FileStat, ResponseDataDetailed } from 'webdav'
+import type { RootDirectory } from './DropServiceUtils'
 
-import { emit } from '@nextcloud/event-bus'
-import { Folder, Node, NodeStatus, davGetClient, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
-import { getUploader, hasConflict, openConflictPicker } 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, 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'
 
-/**
- * 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.
- */
-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)
-       }
-
-}
-
-type RootDirectory = Directory & {
-       name: 'root'
-}
-
-/**
- * Traverse a file tree using the Filesystem API
- * @param entry the entry to traverse
- */
-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()
-       })
-}
-
-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))
-       }
-}
-
-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 []
-}
-
 /**
  * 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.
@@ -225,7 +60,7 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise
                }).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?.webkitGetAsEntry?.()
                                ?? item
                }) as (FileSystemEntry | DataTransferItem)[]
 
@@ -249,7 +84,8 @@ export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise
                        // we therefore cannot upload directories recursively.
                        if (file.type === 'httpd/unix-directory' || !file.type) {
                                if (!warned) {
-                                       showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded.'))
+                                       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
diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts
new file mode 100644 (file)
index 0000000..1502d83
--- /dev/null
@@ -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 (file)
index 0000000..6fd051f
--- /dev/null
@@ -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 []
+}
diff --git a/cypress/e2e/files/FileSystemAPIUtils.ts b/cypress/e2e/files/FileSystemAPIUtils.ts
deleted file mode 100644 (file)
index 995aef2..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-import { basename } from 'node:path'
-
-class FileSystemEntry {
-
-       private _isFile: boolean
-       private _fullPath: string
-
-       constructor(isFile: boolean, fullPath: string) {
-               this._isFile = isFile
-               this._fullPath = fullPath
-       }
-
-       get isFile() {
-               return !!this._isFile
-       }
-
-       get isDirectory() {
-               return !this.isFile
-       }
-
-       get name() {
-               return basename(this._fullPath)
-       }
-
-}
-
-export class FileSystemFileEntry extends FileSystemEntry {
-
-       private _contents: string
-
-       constructor(fullPath: string, contents: string) {
-               super(true, fullPath)
-               this._contents = contents
-       }
-
-       file(success: (file: File) => void) {
-               success(new File([this._contents], this.name))
-       }
-
-}
-
-export class FileSystemDirectoryEntry extends FileSystemEntry {
-
-       private _entries: FileSystemEntry[]
-
-       constructor(fullPath: string, entries: FileSystemEntry[]) {
-               super(false, fullPath)
-               this._entries = entries || []
-       }
-
-       createReader() {
-               return {
-                       readEntries: (success: (entries: FileSystemEntry[]) => void) => {
-                               success(this._entries)
-                       },
-               }
-       }
-
-}
index 4474f5b634c1cca97af38bbd13087e12bbb4c73e..86a3bcfb5719d2377f870b1b593c69cbc6b9c69b 100644 (file)
@@ -1,6 +1,6 @@
 import { getRowForFile } from './FilesUtils.ts'
 
-describe('files: Drag and Drop legacy', { testIsolation: true }, () => {
+describe('files: Drag and Drop', { testIsolation: true }, () => {
        beforeEach(() => {
                cy.createRandomUser().then((user) => {
                        cy.login(user)
index f18589bce5191efa8152d0100b074f65da82d373..68dd68a36ce102817e1b39bf4fa4212bf17d2416 100644 (file)
@@ -26,18 +26,25 @@ const ignorePatterns = [
        '@buttercup/fetch',
        '@juliushaertl',
        '@mdi/svg',
+       '@nextcloud/upload',
        '@nextcloud/vue',
        'ansi-regex',
        'camelcase',
        'char-regex',
        'hot-patcher',
        'is-svg',
+       'mime',
+       'p-cancelable',
+       'p-limit',
+       'p-queue',
+       'p-timeout',
        'splitpanes',
        'string-length',
        'strip-ansi',
        'tributejs',
        'vue-material-design-icons',
        'webdav',
+       'yocto-queue',
 ]
 
 const config: Config = {
index 2e8d1bef9e21c940d205aa8267068bbee7f97dd5..17e7618bd60fa50195d4f5fc2d161a117f7138df 100644 (file)
@@ -56,7 +56,7 @@
     "@nextcloud/paths": "^2.1.0",
     "@nextcloud/router": "^3.0.0",
     "@nextcloud/sharing": "^0.1.0",
-    "@nextcloud/upload": "^1.0.5",
+    "@nextcloud/upload": "^1.1.0",
     "@nextcloud/vue": "^8.11.1",
     "@skjnldsv/sanitize-svg": "^1.0.2",
     "@vueuse/components": "^10.7.2",
     "karma-jasmine-sinon": "^1.0.4",
     "karma-spec-reporter": "^0.0.36",
     "karma-viewport": "^1.0.9",
+    "mime": "^4.0.1",
     "puppeteer": "^22.5.0",
     "raw-loader": "^4.0.2",
     "regextras": "^0.8.0",