summaryrefslogtreecommitdiffstats
path: root/apps/files
diff options
context:
space:
mode:
authorskjnldsv <skjnldsv@protonmail.com>2024-04-02 11:28:59 +0200
committerbackportbot[bot] <backportbot[bot]@users.noreply.github.com>2024-04-04 11:58:34 +0000
commit0aa4e0a675f90a03bce4d9b938d0753a1b610eab (patch)
tree684428d8f87379729241a8bcbb20b0f8efb02810 /apps/files
parentd98738cbd0d2ae7aef36436e34b6ce6e16f325df (diff)
downloadnextcloud-server-0aa4e0a675f90a03bce4d9b938d0753a1b610eab.tar.gz
nextcloud-server-0aa4e0a675f90a03bce4d9b938d0753a1b610eab.zip
chore: add drag and drop recursion and FilesystemAPI testing
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Diffstat (limited to 'apps/files')
-rw-r--r--apps/files/src/components/DragAndDropNotice.vue7
-rw-r--r--apps/files/src/components/FileEntryMixin.ts2
-rw-r--r--apps/files/src/services/DropService.ts178
-rw-r--r--apps/files/src/services/DropServiceUtils.spec.ts142
-rw-r--r--apps/files/src/services/DropServiceUtils.ts195
5 files changed, 350 insertions, 174 deletions
diff --git a/apps/files/src/components/DragAndDropNotice.vue b/apps/files/src/components/DragAndDropNotice.vue
index d591f6ee934..c036c86fb64 100644
--- a/apps/files/src/components/DragAndDropNotice.vue
+++ b/apps/files/src/components/DragAndDropNotice.vue
@@ -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: {
diff --git a/apps/files/src/components/FileEntryMixin.ts b/apps/files/src/components/FileEntryMixin.ts
index 9eead369322..d3a9e80c9b7 100644
--- a/apps/files/src/components/FileEntryMixin.ts
+++ b/apps/files/src/components/FileEntryMixin.ts
@@ -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
}
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
index e5f806d9f0b..d3711741753 100644
--- a/apps/files/src/services/DropService.ts
+++ b/apps/files/src/services/DropService.ts
@@ -22,187 +22,22 @@
*/
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.
* The File API will NOT be available if the browser is not in a secure context (e.g. HTTP).
@@ -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
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 []
+}