diff --git a/packages/web-app-files/src/HandleUpload.ts b/packages/web-app-files/src/HandleUpload.ts index 54044b08d3..00d8054718 100644 --- a/packages/web-app-files/src/HandleUpload.ts +++ b/packages/web-app-files/src/HandleUpload.ts @@ -128,7 +128,8 @@ export class HandleUpload extends BasePlugin file.meta = { ...file.meta, tusEndpoint: endpoint, - uploadId: uuidV4() + uploadId: uuidV4(), + isFolder: file.type === 'directory' } filesToUpload[file.id] = file @@ -171,6 +172,7 @@ export class HandleUpload extends BasePlugin // file data name: file.name, mtime: (file.data as File).lastModified / 1000, + isFolder: file.type === 'directory', // current path & space spaceId: unref(this.space).id, spaceName: unref(this.space).name, @@ -190,6 +192,13 @@ export class HandleUpload extends BasePlugin routeShareId: queryItemAsString(query?.shareId) || '' } + if (file.type === 'directory') { + // folder files need their name appended to the relative folder + // so they can be created correctly. otherwise, only the parent directories + // would be created (because that's the behavior with files). + file.meta.relativeFolder = urlJoin(file.meta.relativeFolder, file.name) + } + filesToUpload[file.id] = file } @@ -278,7 +287,7 @@ export class HandleUpload extends BasePlugin async createDirectoryTree( filesToUpload: OcUppyFile[], uploadFolder: Resource - ): Promise { + ): Promise<{ filesToUpload: OcUppyFile[]; folderFiles: OcUppyFile[] }> { const { webdav } = this.clientService const space = unref(this.space) const { id: currentFolderId, path: currentFolderPath } = uploadFolder @@ -291,7 +300,16 @@ export class HandleUpload extends BasePlugin const directoryTree: Record = {} const topLevelIds: Record = {} + // folder files are manually constructed folders. + // they should not be part of the Uppy upload queue (which only knows files) + // and will be created separately. they need to be filtered out later. + const folderFiles: OcUppyFile[] = [] + for (const file of filesToUpload.filter(({ meta }) => !!meta.relativeFolder)) { + if (file.type === 'directory') { + folderFiles.push(file) + } + const folders = file.meta.relativeFolder.split('/').filter(Boolean) let current = directoryTree // first folder is always top level @@ -312,7 +330,6 @@ export class HandleUpload extends BasePlugin const uppyFile = { id: uuidV4(), name: basename(path), - isFolder: true, type: 'folder', meta: { spaceId: space.id, @@ -325,7 +342,8 @@ export class HandleUpload extends BasePlugin uploadId, routeName, routeDriveAliasAndItem, - routeShareId + routeShareId, + isFolder: true } } @@ -339,7 +357,7 @@ export class HandleUpload extends BasePlugin try { const folder = await webdav.createFolder(space, { path: urlJoin(currentFolderPath, path), - fetchFolder: isRoot + fetchFolder: isRoot // FIXME: remove once we get the fileId from the server here }) this.uppyService.publish('uploadSuccess', { ...uppyFile, @@ -365,17 +383,23 @@ export class HandleUpload extends BasePlugin await createDirectoryLevel(directoryTree) let filesToRemove: string[] = [] - if (failedFolders.length) { - // remove files of folders that could not be created + if (failedFolders.length || folderFiles.length) { + // remove files of folders that could not be created and folder files filesToRemove = filesToUpload - .filter((f) => failedFolders.some((r) => f.meta.relativeFolder.startsWith(r))) + .filter( + (f) => f.meta.isFolder || failedFolders.some((r) => f.meta.relativeFolder.startsWith(r)) + ) .map(({ id }) => id) + for (const fileId of filesToRemove) { this.uppy.removeFile(fileId) } } - return filesToUpload.filter(({ id }) => !filesToRemove.includes(id)) + return { + filesToUpload: filesToUpload.filter(({ id }) => !filesToRemove.includes(id)), + folderFiles + } } /** @@ -391,6 +415,17 @@ export class HandleUpload extends BasePlugin const uploadFolder = this.getUploadFolder(uploadId) let filesToUpload = this.prepareFiles(files, uploadFolder) + if (!this.directoryTreeCreateEnabled) { + // if directory tree creation is disabled, we need to remove all folder files + // from the upload queue + filesToUpload = filesToUpload.filter((file) => file.type !== 'directory') + if (!filesToUpload.length) { + // if there are no files left to upload, we can clear the inputs and do nothing + this.uppyService.clearInputs() + return + } + } + // quota check if (this.quotaCheckEnabled) { const quotaExceeded = this.checkQuotaExceeded(filesToUpload) @@ -426,12 +461,21 @@ export class HandleUpload extends BasePlugin } this.uppyService.publish('uploadStarted') + let folderFiles: OcUppyFile[] = [] if (this.directoryTreeCreateEnabled) { - filesToUpload = await this.createDirectoryTree(filesToUpload, uploadFolder) + const result = await this.createDirectoryTree(filesToUpload, uploadFolder) + filesToUpload = result.filesToUpload + folderFiles = result.folderFiles } if (!filesToUpload.length) { - this.uppyService.publish('uploadCompleted', { successful: [] }) + const successful: OcUppyFile[] = [] + if (folderFiles.length) { + // case where only empty folders have been uploaded + successful.push(...folderFiles) + } + this.uppyService.publish('uploadCompleted', { successful }) + this.uppyService.removeUploadFolder(uploadId) return this.uppyService.clearInputs() } diff --git a/packages/web-app-files/src/components/AppBar/Upload/ResourceUpload.vue b/packages/web-app-files/src/components/AppBar/Upload/ResourceUpload.vue index 0527ac8f73..07d72b12c4 100644 --- a/packages/web-app-files/src/components/AppBar/Upload/ResourceUpload.vue +++ b/packages/web-app-files/src/components/AppBar/Upload/ResourceUpload.vue @@ -28,10 +28,19 @@ diff --git a/packages/web-app-files/src/helpers/directoryPicker.ts b/packages/web-app-files/src/helpers/directoryPicker.ts new file mode 100644 index 0000000000..f2922b8136 --- /dev/null +++ b/packages/web-app-files/src/helpers/directoryPicker.ts @@ -0,0 +1,54 @@ +import { urlJoin } from '@opencloud-eu/web-client' +import { createFolderDummyFile } from '@opencloud-eu/web-pkg' + +/** + * This method uses the Directory API to retrieve items from a directory + * that the user selects from their file system for upload. + * It returns an array of File objects, including dummy files for empty folders. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker + */ +export const getItemsViaDirectoryPicker = async ( + onError?: (error?: unknown) => void +): Promise => { + const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker() + + async function* getItemsFromDirectory( + dirHandle: FileSystemDirectoryHandle, + path: string + ): AsyncGenerator { + let hasFiles = false + + for await (const [name, handle] of (dirHandle as any).entries()) { + if (handle.kind === 'directory') { + // recurse into the directory + yield* getItemsFromDirectory(handle, urlJoin(path, name)) + } else { + hasFiles = true + try { + const file = await handle.getFile() + ;(file as any).relativePath = urlJoin(path, name, { leadingSlash: true }) + yield file + } catch (error) { + console.log(error) + onError?.(error) + } + } + } + + if (!hasFiles) { + // empty folder, create a dummy file to represent it in the Uppy queue. + // note that a folder that only contains other folders is always considered empty, + // even if there are files located further down the hierarchy. this is because we don't + // have insight into the contents of the subfolders at this point. + yield createFolderDummyFile(path) + } + } + + const items: File[] = [] + for await (const item of getItemsFromDirectory(dirHandle, dirHandle.name)) { + items.push(item) + } + + return items +} diff --git a/packages/web-app-files/tests/unit/HandleUpload.spec.ts b/packages/web-app-files/tests/unit/HandleUpload.spec.ts index 6629d08e1c..83bd5635d6 100644 --- a/packages/web-app-files/tests/unit/HandleUpload.spec.ts +++ b/packages/web-app-files/tests/unit/HandleUpload.spec.ts @@ -41,28 +41,39 @@ describe('HandleUpload', () => { instance.removeFilesFromUpload([fileToRemove]) expect(mocks.uppy.removeFile).toHaveBeenCalledWith(fileToRemove.id) }) - it('correctly prepares all files that need to be uploaded', () => { - const { instance, mocks } = getWrapper() - mocks.uppy.getPlugin.mockReturnValue(mock()) - const fileToUpload = mock({ name: 'name' }) - const uploadFolder = mock({ id: '1', path: '/' }) - const processedFiles = instance.prepareFiles([fileToUpload], uploadFolder) + describe('method prepareFiles', () => { + it('correctly prepares all files that need to be uploaded', () => { + const { instance, mocks } = getWrapper() + mocks.uppy.getPlugin.mockReturnValue(mock()) + const fileToUpload = mock({ name: 'name' }) + const uploadFolder = mock({ id: '1', path: '/' }) + const processedFiles = instance.prepareFiles([fileToUpload], uploadFolder) - const route = unref(mocks.opts.route) + const route = unref(mocks.opts.route) - expect(processedFiles[0].tus.endpoint).toEqual('/') - expect(processedFiles[0].meta.name).toEqual(fileToUpload.name) - expect(processedFiles[0].meta.spaceId).toEqual(unref(mocks.opts.space).id) - expect(processedFiles[0].meta.spaceName).toEqual(unref(mocks.opts.space).name) - expect(processedFiles[0].meta.driveAlias).toEqual(unref(mocks.opts.space).driveAlias) - expect(processedFiles[0].meta.driveType).toEqual(unref(mocks.opts.space).driveType) - expect(processedFiles[0].meta.currentFolder).toEqual(uploadFolder.path) - expect(processedFiles[0].meta.currentFolderId).toEqual(uploadFolder.id) - expect(processedFiles[0].meta.tusEndpoint).toEqual(uploadFolder.path) - expect(processedFiles[0].meta.relativeFolder).toEqual('') - expect(processedFiles[0].meta.routeName).toEqual(route.name) - expect(processedFiles[0].meta.routeDriveAliasAndItem).toEqual(route.params.driveAliasAndItem) - expect(processedFiles[0].meta.routeShareId).toEqual(route.query.shareId) + expect(processedFiles[0].tus.endpoint).toEqual('/') + expect(processedFiles[0].meta.name).toEqual(fileToUpload.name) + expect(processedFiles[0].meta.spaceId).toEqual(unref(mocks.opts.space).id) + expect(processedFiles[0].meta.spaceName).toEqual(unref(mocks.opts.space).name) + expect(processedFiles[0].meta.driveAlias).toEqual(unref(mocks.opts.space).driveAlias) + expect(processedFiles[0].meta.driveType).toEqual(unref(mocks.opts.space).driveType) + expect(processedFiles[0].meta.currentFolder).toEqual(uploadFolder.path) + expect(processedFiles[0].meta.currentFolderId).toEqual(uploadFolder.id) + expect(processedFiles[0].meta.tusEndpoint).toEqual(uploadFolder.path) + expect(processedFiles[0].meta.relativeFolder).toEqual('') + expect(processedFiles[0].meta.routeName).toEqual(route.name) + expect(processedFiles[0].meta.routeDriveAliasAndItem).toEqual(route.params.driveAliasAndItem) + expect(processedFiles[0].meta.routeShareId).toEqual(route.query.shareId) + }) + it('includes the folder name in the "relativeFolder" prop for folder files', () => { + const { instance, mocks } = getWrapper() + mocks.uppy.getPlugin.mockReturnValue(mock()) + const fileToUpload = mock({ name: 'name', type: 'directory' }) + const uploadFolder = mock({ id: '1', path: '/' }) + const processedFiles = instance.prepareFiles([fileToUpload], uploadFolder) + + expect(processedFiles[0].meta.relativeFolder).toEqual(`/${fileToUpload.name}`) + }) }) describe('method createDirectoryTree', () => { it('creates a directory for a single file with a relative folder given', async () => { @@ -74,13 +85,12 @@ describe('HandleUpload', () => { mocks.opts.clientService.webdav.createFolder.mockResolvedValue(createdFolder) const uploadFolder = mock({ id: '1', path: '/' }) - const result = await instance.createDirectoryTree([fileToUpload], uploadFolder) + const { filesToUpload } = await instance.createDirectoryTree([fileToUpload], uploadFolder) expect(mocks.opts.uppyService.publish).toHaveBeenCalledWith( 'uploadSuccess', expect.objectContaining({ name: relativeFolder.split('/')[1], - isFolder: true, type: 'folder', meta: expect.objectContaining({ spaceId: unref(mocks.opts.space).id, @@ -93,7 +103,8 @@ describe('HandleUpload', () => { routeName: fileToUpload.meta.routeName, routeDriveAliasAndItem: fileToUpload.meta.routeDriveAliasAndItem, routeShareId: fileToUpload.meta.routeShareId, - fileId: createdFolder.fileId + fileId: createdFolder.fileId, + isFolder: true }) }) ) @@ -105,7 +116,7 @@ describe('HandleUpload', () => { fetchFolder: true } ) - expect(result.length).toBe(1) + expect(filesToUpload.length).toBe(1) }) it('filters out files whose folders could not be created', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -116,11 +127,34 @@ describe('HandleUpload', () => { const fileToUpload = mock({ name: 'name', meta: { relativeFolder } }) mocks.opts.clientService.webdav.createFolder.mockRejectedValue({}) - const result = await instance.createDirectoryTree([fileToUpload], mock()) + const { filesToUpload } = await instance.createDirectoryTree([fileToUpload], mock()) expect(mocks.opts.uppyService.publish).toHaveBeenCalledWith('uploadError', expect.anything()) expect(mocks.uppy.removeFile).toHaveBeenCalled() - expect(result.length).toBe(0) + expect(filesToUpload.length).toBe(0) + }) + it('returns folder files and removes them from the uppy upload files', async () => { + const { instance, mocks } = getWrapper() + mocks.uppy.getPlugin.mockReturnValue(mock()) + const relativeFolder = '/relativeFolder' + const fileToUpload = mock({ + name: 'directory', + type: 'directory', + meta: { relativeFolder } + }) + const createdFolder = mock() + mocks.opts.clientService.webdav.createFolder.mockResolvedValue(createdFolder) + + const uploadFolder = mock({ id: '1', path: '/' }) + const { filesToUpload, folderFiles } = await instance.createDirectoryTree( + [fileToUpload], + uploadFolder + ) + + expect(instance.uppy.removeFile).toHaveBeenCalled() + expect(mocks.opts.clientService.webdav.createFolder).toHaveBeenCalledTimes(1) + expect(filesToUpload.length).toBe(0) + expect(folderFiles.length).toBe(1) }) }) describe('method handleUpload', () => { @@ -155,7 +189,7 @@ describe('HandleUpload', () => { { size: 10, remaining: 90, driveType: 'personal', quotaExceeded: false } ])( 'returns a correct result after quota has been checked for own personal and project spaces', - async ({ size, remaining, driveType, quotaExceeded }) => { + ({ size, remaining, driveType, quotaExceeded }) => { const space = mock({ driveType, id: '1', @@ -163,7 +197,7 @@ describe('HandleUpload', () => { isOwner: () => true }) const { instance } = getWrapper({ spaces: [space] }) - const result = await instance.checkQuotaExceeded([ + const result = instance.checkQuotaExceeded([ mock({ name: 'name', meta: { spaceId: '1', routeName: locationSpacesGeneric.name as string }, @@ -173,7 +207,7 @@ describe('HandleUpload', () => { expect(result).toBe(quotaExceeded) } ) - it('does not check quota for share spaces', async () => { + it('does not check quota for share spaces', () => { const size = 100 const remaining = 90 const space = mock({ @@ -182,7 +216,7 @@ describe('HandleUpload', () => { spaceQuota: { remaining } }) const { instance } = getWrapper({ spaces: [space] }) - const result = await instance.checkQuotaExceeded([ + const result = instance.checkQuotaExceeded([ mock({ name: 'name', meta: { spaceId: '1', routeName: locationSpacesGeneric.name as string }, @@ -191,7 +225,7 @@ describe('HandleUpload', () => { ]) expect(result).toBeFalsy() }) - it("does not check quota for other's personal spaces", async () => { + it("does not check quota for other's personal spaces", () => { const size = 100 const remaining = 90 const space = mock({ @@ -201,7 +235,7 @@ describe('HandleUpload', () => { isOwner: () => false }) const { instance } = getWrapper({ spaces: [space] }) - const result = await instance.checkQuotaExceeded([ + const result = instance.checkQuotaExceeded([ mock({ name: 'name', meta: { spaceId: '1', routeName: locationSpacesGeneric.name as string }, diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 29f2944b23..1f438b4a25 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -41,7 +41,6 @@ "@opencloud-eu/web-client": "workspace:^", "@sentry/vue": "^9.0.0", "@uppy/core": "^4.3.1", - "@uppy/drop-target": "^3.0.2", "@uppy/tus": "^4.1.5", "@uppy/utils": "^6.0.6", "@uppy/xhr-upload": "^4.2.3", diff --git a/packages/web-pkg/src/services/uppy/DropTarget/getDroppedFiles.ts b/packages/web-pkg/src/services/uppy/DropTarget/getDroppedFiles.ts new file mode 100644 index 0000000000..1ba5aa5fd4 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/DropTarget/getDroppedFiles.ts @@ -0,0 +1,107 @@ +import { urlJoin } from '@opencloud-eu/web-client' +import { createFolderDummyFile } from '../utils' + +/** + * Methods to retrieve dropped files from a DataTransfer object. + * This is inspired by WICG examples and the Uppy `getDroppedFiles` function. + * + * https://wicg.github.io/entries-api/#api-directoryreader + * https://github.com/transloadit/uppy/blob/main/packages/%40uppy/utils/src/getDroppedFiles/README.md + */ + +const convertEntryToFile = async (entry: FileSystemFileEntry): Promise => { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)) + const isRootFile = entry.fullPath === urlJoin(entry.name, { leadingSlash: true }) + if (!isRootFile) { + // add relativePath to the file object just like Uppy does and expects it + ;(file as any).relativePath = entry.fullPath + } + + return file as File +} + +const getAsEntry = (item: any): ReturnType => { + // `webkitGetAsEntry` might get renamed to `getAsEntry` in the future. + // see https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry + return typeof item.getAsEntry === 'function' ? item.getAsEntry() : item.webkitGetAsEntry() +} + +async function* readDirectory(dirEntry: FileSystemDirectoryEntry): AsyncGenerator { + const reader = dirEntry.createReader() + const getNextBatch = () => + new Promise((resolve, reject) => { + reader.readEntries(resolve, reject) + }) + + let entries: FileSystemEntry[] + do { + entries = await getNextBatch() + for (const entry of entries) { + yield entry + } + } while (entries.length > 0) +} + +async function* getFile( + entry: FileSystemEntry, + logDropError?: (error?: unknown) => void +): AsyncGenerator { + if (entry.isDirectory) { + let hasFiles = false // check for empty directories later + + for await (const e of readDirectory(entry as FileSystemDirectoryEntry)) { + if (e.isDirectory) { + // recurse into the directory + yield* getFile(e, logDropError) + } else if (e.isFile) { + try { + hasFiles = true + yield convertEntryToFile(e as FileSystemFileEntry) + } catch (error) { + console.error(error) + logDropError?.(error) + } + } + } + + if (!hasFiles) { + // empty folder, create a dummy file to represent it in the Uppy queue. + // note that a folder that only contains other folders is always considered empty, + // even if there are files located further down the hierarchy. this is because we don't + // have insight into the contents of the subfolders at this point. + yield createFolderDummyFile(entry.fullPath || entry.name) + } + } else { + try { + yield convertEntryToFile(entry as FileSystemFileEntry) + } catch (error) { + console.error(error) + logDropError?.(error) + } + } +} + +export const getDroppedFiles = async ( + dataTransfer: DataTransfer, + logDropError?: (error?: unknown) => void +): Promise => { + try { + const files: File[] = [] + + // convert DataTransfer items to FileSystemEntry objects. this needs to happen at the beginning, + // otherwise only the first item will be processed. + const items = await Promise.all(Array.from(dataTransfer.items, (item) => getAsEntry(item))) + + for (let i = 0; i < items.length; i++) { + for await (const file of getFile(items[i], logDropError)) { + files.push(file) + } + } + + return files + } catch (e) { + console.error(e) + const files = Array.from(dataTransfer.files) + return Promise.resolve(files) + } +} diff --git a/packages/web-pkg/src/services/uppy/DropTarget/plugin.ts b/packages/web-pkg/src/services/uppy/DropTarget/plugin.ts new file mode 100644 index 0000000000..adbdd3e585 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/DropTarget/plugin.ts @@ -0,0 +1,129 @@ +import type { Body, DefinePluginOpts, Meta, Uppy } from '@uppy/core' +import { BasePlugin } from '@uppy/core' +import { getDroppedFiles } from './getDroppedFiles' +import toArray from '@uppy/utils/lib/toArray' +import { DropTargetOptions } from './types' +import { convertToMinimalUppyFile } from '../utils' + +const defaultOpts = { + target: null, + uppyService: null +} satisfies DropTargetOptions + +/** + * This is an adaption of the official Uppy DropTarget plugin, extended by the + * functionality to handle empty folders and integrated into our UppyService. + * https://github.com/transloadit/uppy/tree/main/packages/%40uppy/drop-target + */ +export default class DropTarget extends BasePlugin< + DefinePluginOpts, + M, + B +> { + private nodes?: Array + private uppyService?: DropTargetOptions['uppyService'] + + constructor(uppy: Uppy, opts?: DropTargetOptions) { + super(uppy, { ...defaultOpts, ...opts }) + this.type = 'acquirer' + this.id = this.opts.id || 'DropTarget' + } + + handleDrop = async (event: DragEvent): Promise => { + event.preventDefault() + event.stopPropagation() + + this.setPluginState({ isDraggingOver: false }) + + this.uppy.iteratePlugins((plugin) => { + if (plugin.type === 'acquirer') { + // @ts-expect-error Every Plugin with .type acquirer can define handleRootDrop(event) + plugin.handleRootDrop?.(event) + } + }) + + let executedDropErrorOnce = false + const logDropError = (error: Error): void => { + this.uppy.log(error, 'error') + + if (!executedDropErrorOnce) { + this.uppy.info(error.message, 'error') + executedDropErrorOnce = true + } + } + + const files = await getDroppedFiles(event.dataTransfer, logDropError) + + if (files.length > 0) { + this.uppy.log('[DropTarget] Files were dropped') + + try { + const uppyFiles = convertToMinimalUppyFile('DropTarget', files) + this.uppyService.addFiles(uppyFiles) + } catch (err) { + this.uppy.log(err) + } + } + + this.opts.onDrop?.(event) + this.uppyService?.publish('drop', event) + } + + handleDragOver = (event: DragEvent): void => { + event.preventDefault() + event.stopPropagation() + + this.setPluginState({ isDraggingOver: true }) + this.opts.onDragOver?.(event) + this.uppyService?.publish('drag-over', event) + } + + handleDragLeave = (event: DragEvent): void => { + event.preventDefault() + event.stopPropagation() + + this.setPluginState({ isDraggingOver: false }) + this.opts.onDragLeave?.(event) + this.uppyService?.publish('drag-out', event) + } + + addListeners = (): void => { + const { target, uppyService } = this.opts + this.uppyService = uppyService + + if (target instanceof Element) { + this.nodes = [target] + } else if (typeof target === 'string') { + this.nodes = toArray(document.querySelectorAll(target)) + } + + if (!this.nodes || this.nodes.length === 0) { + throw new Error(`"${target}" does not match any HTML elements`) + } + + this.nodes.forEach((node) => { + node.addEventListener('dragover', this.handleDragOver, false) + node.addEventListener('dragleave', this.handleDragLeave, false) + node.addEventListener('drop', this.handleDrop, false) + }) + } + + removeListeners = (): void => { + if (this.nodes) { + this.nodes.forEach((node) => { + node.removeEventListener('dragover', this.handleDragOver, false) + node.removeEventListener('dragleave', this.handleDragLeave, false) + node.removeEventListener('drop', this.handleDrop, false) + }) + } + } + + install(): void { + this.setPluginState({ isDraggingOver: false }) + this.addListeners() + } + + uninstall(): void { + this.removeListeners() + } +} diff --git a/packages/web-pkg/src/services/uppy/DropTarget/types.ts b/packages/web-pkg/src/services/uppy/DropTarget/types.ts new file mode 100644 index 0000000000..b401841b9c --- /dev/null +++ b/packages/web-pkg/src/services/uppy/DropTarget/types.ts @@ -0,0 +1,10 @@ +import { PluginOpts } from '@uppy/core' +import { UppyService } from '../uppyService' + +export interface DropTargetOptions extends PluginOpts { + target?: HTMLElement | string | null + uppyService?: UppyService + onDrop?: (event: DragEvent) => void + onDragOver?: (event: DragEvent) => void + onDragLeave?: (event: DragEvent) => void +} diff --git a/packages/web-pkg/src/services/uppy/index.ts b/packages/web-pkg/src/services/uppy/index.ts index cbc67cad32..cf5431b77f 100644 --- a/packages/web-pkg/src/services/uppy/index.ts +++ b/packages/web-pkg/src/services/uppy/index.ts @@ -1 +1,2 @@ export * from './uppyService' +export * from './utils' diff --git a/packages/web-pkg/src/services/uppy/uppyService.ts b/packages/web-pkg/src/services/uppy/uppyService.ts index fbabd0ecf8..955df88c17 100644 --- a/packages/web-pkg/src/services/uppy/uppyService.ts +++ b/packages/web-pkg/src/services/uppy/uppyService.ts @@ -4,10 +4,8 @@ import { TusOptions } from '@uppy/tus' import XHRUpload, { XHRUploadOptions } from '@uppy/xhr-upload' import { Language } from 'vue3-gettext' import { eventBus } from '../eventBus' -import DropTarget from '@uppy/drop-target' +import DropTarget from './DropTarget/plugin' import { Resource, urlJoin } from '@opencloud-eu/web-client' - -// @ts-ignore import generateFileID from '@uppy/utils/lib/generateFileID' import { Body, MinimalRequiredUppyFile } from '@uppy/utils/lib/UppyFile' @@ -99,7 +97,7 @@ export class UppyService { } file.meta.relativePath = this.getRelativeFilePath(file) // id needs to be generated after the relative path has been set. - file.id = generateFileID(file, this.uppy.getID()) + file.id = this.generateUploadId(file) return file } }) @@ -228,21 +226,9 @@ export class UppyService { } useDropTarget({ targetSelector }: { targetSelector: string }) { - if (this.uppy.getPlugin('DropTarget')) { - return + if (!this.uppy.getPlugin('DropTarget')) { + this.uppy.use(DropTarget, { target: targetSelector, uppyService: this }) } - this.uppy.use(DropTarget, { - target: targetSelector, - onDragOver: (event) => { - this.publish('drag-over', event) - }, - onDragLeave: (event) => { - this.publish('drag-out', event) - }, - onDrop: (event) => { - this.publish('drop', event) - } - }) } removeDropTarget() { @@ -314,6 +300,10 @@ export class UppyService { return generateFileID(uppyFile, this.uppy.getID()) } + log(message: unknown, type?: 'error' | 'warning'): void { + this.uppy.log(message, type) + } + addFiles(files: OcMinimalUppyFile[] | File[]) { // uppy types say they do not accept File[] but they are wrong this.uppy.addFiles(files as OcMinimalUppyFile[]) diff --git a/packages/web-pkg/src/services/uppy/utils.ts b/packages/web-pkg/src/services/uppy/utils.ts new file mode 100644 index 0000000000..9c92b70908 --- /dev/null +++ b/packages/web-pkg/src/services/uppy/utils.ts @@ -0,0 +1,37 @@ +import { basename } from 'path' +import { OcMinimalUppyFile } from './uppyService' + +/** + * Constructs a file-like object for a given folder path. This acts as a dummy + * to represent folders in the Uppy queue. + */ +export const createFolderDummyFile = (path: string): File => { + return { + // only use the name of the folder, not the full path + name: basename(path), + + // fake directory mime type so it can be identified as a folder later + type: 'directory', + + // set path as relativePath. this differs a bit from files where root files don't + // have a relativePath. + relativePath: path + } as any +} + +/** + * Converts a list of files to the minimal Uppy file format. + * This is used to add files to the Uppy queue via addFiles() (see below). + */ +export const convertToMinimalUppyFile = (source: string, files: File[]): OcMinimalUppyFile[] => { + return files.map((file) => ({ + source, + name: file.name, + type: file.type, + data: file, + meta: { + // file path relative to the directory the user selected + relativePath: (file as any).relativePath || null + } as any + })) +} diff --git a/packages/web-pkg/tests/unit/services/uppy/DropTarget/getDroppedFiles.spec.ts b/packages/web-pkg/tests/unit/services/uppy/DropTarget/getDroppedFiles.spec.ts new file mode 100644 index 0000000000..7b451c8b66 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/uppy/DropTarget/getDroppedFiles.spec.ts @@ -0,0 +1,120 @@ +import { getDroppedFiles } from '../../../../../src/services/uppy/DropTarget/getDroppedFiles' + +describe('getDroppedFiles', () => { + it('retrieves a single file from a given DataTransfer', async () => { + const expectedFileObj = new File(['Hello World'], 'foo.txt', { type: 'text/plain' }) + const file = { + getAsEntry: () => ({ + isFile: true, + isDirectory: false, + file: (resolve) => resolve(expectedFileObj) + }) + } as any + + const dataTransfer = { items: [file] } as any + const files = await getDroppedFiles(dataTransfer) + + expect(files).toContain(expectedFileObj) + }) + + it('retrieves a file from inside a nested folder from a given DataTransfer', async () => { + const expectedFileObj = new File(['Hello World'], 'foo.txt', { type: 'text/plain' }) + const file = { + isFile: true, + isDirectory: false, + file: (resolve) => resolve(expectedFileObj) + } as any + + let folderInsideFinished = false + const folderInside = { + isFile: false, + isDirectory: true, + fullPath: '/rootFolder/folderInside', + createReader: () => ({ + readEntries: (callback: (entries: any[]) => void) => { + callback(!folderInsideFinished ? [file] : []) + folderInsideFinished = true + } + }) + } + + let rootFolderFinished = false + const rootFolder = { + getAsEntry: () => + ({ + isFile: false, + isDirectory: true, + fullPath: '/rootFolder', + createReader: () => ({ + readEntries: (callback: (entries: any[]) => void) => { + callback(!rootFolderFinished ? [folderInside] : []) + rootFolderFinished = true + } + }) + }) as any + } + + const dataTransfer = { items: [rootFolder] } as any + const files = await getDroppedFiles(dataTransfer) + + expect(files).toContain(expectedFileObj) + }) + + it('returns empty folders', async () => { + const folder = { + getAsEntry: () => + ({ + isFile: false, + isDirectory: true, + fullPath: '/folder', + createReader: () => ({ + readEntries: (callback: (entries: any[]) => void) => { + callback([]) + } + }) + }) as any + } + + const dataTransfer = { items: [folder] } as any + const files = await getDroppedFiles(dataTransfer) + + expect((files[0] as any).relativePath).toEqual('/folder') + }) + + describe('error handling', () => { + it('calls a callback when the conversion from entry to file fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const error = new Error('entry to file conversion failed') + const file = { + getAsEntry: () => ({ + isFile: true, + isDirectory: false, + file: (resolve, reject) => reject(error) + }) + } as any + + const dataTransfer = { items: [file] } as any + const onError = vi.fn() + await getDroppedFiles(dataTransfer, onError) + + expect(onError).toHaveBeenCalledWith(error) + expect(consoleErrorSpy).toHaveBeenCalledWith(error) + }) + + it('falls back to dataTransfer.files when getAsEntry fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const error = new Error('getAsEntry failed') + const file = { + getAsEntry: () => { + throw error + } + } as any + + const dataTransfer = { items: [file], files: [file] } as any + const files = await getDroppedFiles(dataTransfer) + + expect(files).toEqual([file]) + expect(consoleErrorSpy).toHaveBeenCalledWith(error) + }) + }) +}) diff --git a/packages/web-pkg/tests/unit/services/uppy/utils.spec.ts b/packages/web-pkg/tests/unit/services/uppy/utils.spec.ts new file mode 100644 index 0000000000..4882a9f1d8 --- /dev/null +++ b/packages/web-pkg/tests/unit/services/uppy/utils.spec.ts @@ -0,0 +1,35 @@ +import { + convertToMinimalUppyFile, + createFolderDummyFile +} from '../../../../src/services/uppy/utils' + +describe('createFolderDummyFile', () => { + it('creates a dummy file for a directory', () => { + const dummyFile = createFolderDummyFile('/folder') + + expect(dummyFile.name).toBe('folder') + expect(dummyFile.type).toBe('directory') + expect((dummyFile as any).relativePath).toBe('/folder') + }) +}) + +describe('convertToMinimalUppyFile', () => { + it('converts a list of files to the minimal Uppy file format', () => { + const source = 'testSource' + const file = new File(['content'], 'file1.txt', { type: 'text/plain' }) + ;(file as any).relativePath = '/folder/file1.txt' + const result = convertToMinimalUppyFile(source, [file]) + + expect(result).toEqual([ + { + source, + name: 'file1.txt', + type: 'text/plain', + data: expect.anything(), + meta: { + relativePath: '/folder/file1.txt' + } + } + ]) + }) +}) diff --git a/packages/web-runtime/package.json b/packages/web-runtime/package.json index 6cc78dc964..29231debb3 100644 --- a/packages/web-runtime/package.json +++ b/packages/web-runtime/package.json @@ -13,7 +13,6 @@ "@opencloud-eu/web-pkg": "workspace:*", "@sentry/vue": "9.43.0", "@uppy/core": "4.4.7", - "@uppy/drop-target": "3.1.1", "@uppy/tus": "4.2.2", "@uppy/utils": "6.1.5", "@uppy/xhr-upload": "4.3.3", diff --git a/packages/web-runtime/src/components/UploadInfo.vue b/packages/web-runtime/src/components/UploadInfo.vue index 23bab90dec..ec0fff1de9 100644 --- a/packages/web-runtime/src/components/UploadInfo.vue +++ b/packages/web-runtime/src/components/UploadInfo.vue @@ -5,7 +5,7 @@ >

import { defineComponent, ref, watch, unref } from 'vue' import { isUndefined } from 'lodash-es' -// @ts-ignore import getSpeed from '@uppy/utils/lib/getSpeed' - import { HttpError, Resource, urlJoin } from '@opencloud-eu/web-client' import { OcUppyFile, queryItemAsString, useConfigStore } from '@opencloud-eu/web-pkg' import { formatFileSize, ResourceListItem, ResourceIcon, ResourceName } from '@opencloud-eu/web-pkg' @@ -187,8 +185,8 @@ export default defineComponent({ const infoExpanded = ref(false) // show the info including all uploads? const uploads = ref>({}) // uploads that are being displayed via "infoExpanded" const errors = ref>({}) // all failed files - const successful = ref([]) // all successful files - const filesInProgressCount = ref(0) // files (not folders!) that are being processed currently + const successful = ref([]) // all successful root level items + const itemsInProgressCount = ref(0) // root level files and folders that are being processed currently const totalProgress = ref(0) // current uploads progress (0-100) const uploadsPaused = ref(false) // all uploads paused? const uploadsCancelled = ref(false) // all uploads cancelled? @@ -223,7 +221,7 @@ export default defineComponent({ uploads, errors, successful, - filesInProgressCount, + itemsInProgressCount, totalProgress, uploadsPaused, uploadsCancelled, @@ -259,12 +257,12 @@ export default defineComponent({ return this.$gettext('Finalizing upload...') } - if (this.filesInProgressCount && !this.inPreparation) { + if (this.itemsInProgressCount && !this.inPreparation) { return this.$ngettext( '%{ filesInProgressCount } item uploading...', '%{ filesInProgressCount } items uploading...', - this.filesInProgressCount, - { filesInProgressCount: (this.filesInProgressCount as number).toString() } + this.itemsInProgressCount, + { filesInProgressCount: this.itemsInProgressCount.toString() } ) } if (this.uploadsCancelled) { @@ -332,7 +330,8 @@ export default defineComponent({ this.inFinalization = false }) this.$uppyService.subscribe('addedForUpload', (files: OcUppyFile[]) => { - this.filesInProgressCount += files.filter((f) => !f.meta.isFolder).length + // only count root level files and folders + this.itemsInProgressCount += files.filter((f) => !f.meta.relativeFolder).length for (const file of files) { if (!this.disableActions && file.isRemote) { @@ -430,50 +429,44 @@ export default defineComponent({ this.uploads[file.meta.uploadId].targetRoute = this.buildRouteFromUppyResource(file) this.uploads[file.meta.uploadId].status = 'error' this.errors[file.meta.uploadId] = error as HttpError - this.filesInProgressCount -= 1 - if (file.meta.topLevelFolderId) { - this.handleTopLevelFolderUpdate(file, 'error') - } - } - ) - this.$uppyService.subscribe('uploadSuccess', (file: OcUppyFile) => { - // item inside folder - if (!this.uploads[file.meta.uploadId]) { if (!file.meta.isFolder) { - this.successful.push(file.meta.uploadId) - this.filesInProgressCount -= 1 + if (!file.meta.relativeFolder) { + // reduce count for failed root level files. count for folders is handled in handleTopLevelFolderUpdate + this.itemsInProgressCount -= 1 + } if (file.meta.topLevelFolderId) { - this.handleTopLevelFolderUpdate(file, 'success') + this.handleTopLevelFolderUpdate(file, 'error') } } - - return } + ) + this.$uppyService.subscribe('uploadSuccess', (file: OcUppyFile) => { + // item inside folder + if (!this.uploads[file.meta.uploadId] || file.meta.relativeFolder) { + if (!file.meta.isFolder && file.meta.topLevelFolderId) { + this.handleTopLevelFolderUpdate(file, 'success') + } - // file inside folder that succeeded via retry can now be removed again from this.uploads - if (file.meta.relativeFolder) { - if (!file.meta.isFolder) { - this.successful.push(file.meta.uploadId) - this.filesInProgressCount -= 1 - if (file.meta.topLevelFolderId) { - this.handleTopLevelFolderUpdate(file, 'success') - } + if (this.uploads[file.meta.uploadId]) { + // retries end up in this.uploads, even if they're not at the top level. + // a succeeded retry can now be removed from this.uploads. + delete this.uploads[file.meta.uploadId] } - delete this.uploads[file.meta.uploadId] return } this.uploads[file.meta.uploadId] = file this.uploads[file.meta.uploadId].path = urlJoin(file.meta.currentFolder, file.name) this.uploads[file.meta.uploadId].targetRoute = this.buildRouteFromUppyResource(file) + this.uploads[file.meta.uploadId].status = 'success' + this.successful.push(file.meta.uploadId) if (!file.meta.isFolder) { - this.uploads[file.meta.uploadId].status = 'success' - this.successful.push(file.meta.uploadId) - this.filesInProgressCount -= 1 + // reduce count for succeeded root level files. count for folders is handled in handleTopLevelFolderUpdate + this.itemsInProgressCount -= 1 } }) }, @@ -512,6 +505,7 @@ export default defineComponent({ // all files for this top level folder are finished if (topLevelFolder.successCount + topLevelFolder.errorCount === topLevelFolder.filesCount) { topLevelFolder.status = topLevelFolder.errorCount ? 'error' : 'success' + this.itemsInProgressCount -= 1 } }, closeInfo() { @@ -525,7 +519,7 @@ export default defineComponent({ this.uploads = {} this.errors = {} this.successful = [] - this.filesInProgressCount = 0 + this.itemsInProgressCount = 0 this.runningUploads = 0 this.disableActions = false }, @@ -616,7 +610,7 @@ export default defineComponent({ this.infoExpanded = !this.infoExpanded }, retryUploads() { - this.filesInProgressCount += Object.keys(this.errors).length + this.itemsInProgressCount += Object.keys(this.errors).length this.runningUploads += 1 for (const fileID of Object.keys(this.errors)) { this.uploads[fileID].status = undefined @@ -642,7 +636,7 @@ export default defineComponent({ }, cancelAllUploads() { this.uploadsCancelled = true - this.filesInProgressCount = 0 + this.itemsInProgressCount = 0 this.runningUploads = 0 this.resetProgress() this.$uppyService.cancelAllUploads() diff --git a/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts b/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts index 26e34725d5..043f57f9fe 100644 --- a/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts +++ b/packages/web-runtime/tests/unit/components/UploadInfo.spec.ts @@ -35,7 +35,7 @@ describe('UploadInfo component', () => { const { wrapper } = getShallowWrapper() wrapper.vm.showInfo = true wrapper.vm.inPreparation = false - wrapper.vm.filesInProgressCount = 1 + wrapper.vm.itemsInProgressCount = 1 wrapper.vm.runningUploads = 1 await nextTick() @@ -81,7 +81,7 @@ describe('UploadInfo component', () => { it('should show that an upload is being finalized', async () => { const { wrapper } = getShallowWrapper() wrapper.vm.showInfo = true - wrapper.vm.filesInProgressCount = 1 + wrapper.vm.itemsInProgressCount = 1 wrapper.vm.runningUploads = 1 wrapper.vm.inFinalization = true await nextTick() @@ -94,7 +94,7 @@ describe('UploadInfo component', () => { it('should show the progress bar when an upload is in progress', async () => { const { wrapper } = getShallowWrapper() wrapper.vm.showInfo = true - wrapper.vm.filesInProgressCount = 1 + wrapper.vm.itemsInProgressCount = 1 wrapper.vm.runningUploads = 1 await nextTick() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 300ea0e2c3..b7be67d09b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -816,9 +816,6 @@ importers: '@uppy/core': specifier: ^4.3.1 version: 4.4.7 - '@uppy/drop-target': - specifier: ^3.0.2 - version: 3.1.1(@uppy/core@4.4.7) '@uppy/tus': specifier: ^4.1.5 version: 4.2.2(@uppy/core@4.4.7) @@ -962,9 +959,6 @@ importers: '@uppy/core': specifier: 4.4.7 version: 4.4.7 - '@uppy/drop-target': - specifier: 3.1.1 - version: 3.1.1(@uppy/core@4.4.7) '@uppy/tus': specifier: 4.2.2 version: 4.2.2(@uppy/core@4.4.7) @@ -2998,11 +2992,6 @@ packages: '@uppy/core@4.4.7': resolution: {integrity: sha512-ZEdRiVnkHVITS7afBCWxQGNKOZ22DFXoDb4ZcLK2Srp5iBnxUbg1rV3sntHDNjZq7QHQAtZqHKAF8RxE6ZSNeg==} - '@uppy/drop-target@3.1.1': - resolution: {integrity: sha512-/2jnQ3DqfcWGjgoasLBLvwJ3fozavwSXFVULenDmPUI8YPjuxmEtOu61XnZ/OLhRnZo6Qm+kltSd+YUS0P/LNA==} - peerDependencies: - '@uppy/core': ^4.4.1 - '@uppy/store-default@4.2.0': resolution: {integrity: sha512-PieFVa8yTvRHIqsNKfpO/yaJw5Ae/hT7uT58ryw7gvCBY5bHrNWxH5N0XFe8PFHMpLpLn8v3UXGx9ib9QkB6+Q==} @@ -8914,11 +8903,6 @@ snapshots: nanoid: 5.1.5 preact: 10.26.5 - '@uppy/drop-target@3.1.1(@uppy/core@4.4.7)': - dependencies: - '@uppy/core': 4.4.7 - '@uppy/utils': 6.1.5 - '@uppy/store-default@4.2.0': {} '@uppy/tus@4.2.2(@uppy/core@4.4.7)':