From 6b9d7eaed25e5f66f970bf8436e24dc37e58269a Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 26 Apr 2024 16:49:27 +0200 Subject: [PATCH 1/4] feat: Add `Directory` helper class to allow building virtual file trees This is required for uploading folders, as `` will provide a flat array of files with just the relative path provided. So we can build the virtual file tree and upload layer by layer. Signed-off-by: Ferdinand Thiessen --- __tests__/setup.ts | 2 + __tests__/utils/fileTree.spec.ts | 167 +++++++++++++++++++++++++++++++ lib/utils/fileTree.ts | 95 ++++++++++++++++++ package-lock.json | 1 + vitest.config.ts | 1 + 5 files changed, 266 insertions(+) create mode 100644 __tests__/setup.ts create mode 100644 __tests__/utils/fileTree.spec.ts create mode 100644 lib/utils/fileTree.ts diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 00000000..9887e310 --- /dev/null +++ b/__tests__/setup.ts @@ -0,0 +1,2 @@ +// Missing is jsdom, ref: https://github.com/jsdom/jsdom/issues/2555 +import 'blob-polyfill' diff --git a/__tests__/utils/fileTree.spec.ts b/__tests__/utils/fileTree.spec.ts new file mode 100644 index 00000000..eca9dbb1 --- /dev/null +++ b/__tests__/utils/fileTree.spec.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import { Directory } from '../../lib/utils/fileTree.ts' + +describe('file tree utils', () => { + it('Can create a directory', () => { + const dir = new Directory('base') + // expected no exception + expect(dir.name).toBe(dir.webkitRelativePath) + expect(dir.name).toBe('base') + }) + + it('Can create a virtual root directory', () => { + const dir = new Directory('') + // expected no exception + expect(dir.name).toBe(dir.webkitRelativePath) + expect(dir.name).toBe('') + }) + + it('Can create a nested directory', () => { + const dir = new Directory('base/name') + // expected no exception + expect(dir.name).toBe('name') + expect(dir.webkitRelativePath).toBe('base/name') + }) + + it('Can create a directory with content', () => { + const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file')]) + // expected no exception + expect(dir.name).toBe('base') + expect(dir.children).toHaveLength(1) + expect(dir.children[0].name).toBe('my-file') + }) + + it('Can create a virtual root with content', async () => { + const dir = new Directory('', [new File(['I am bar.txt'], 'a/bar.txt')]) + expect(dir.name).toBe('') + + expect(dir.children).toHaveLength(1) + expect(dir.children[0]).toBeInstanceOf(Directory) + expect(dir.children[0].name).toBe('a') + expect(dir.children[0].webkitRelativePath).toBe('a') + + const dirA = dir.children[0] as Directory + expect(dirA.children).toHaveLength(1) + expect(await dirA.children[0].text()).toBe('I am bar.txt') + }) + + it('Reads the size from the content', () => { + const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file')]) + // expected no exception + expect(dir.children).toHaveLength(1) + expect(dir.size).toBe(1024) + }) + + it('Reads the lastModified from the content', () => { + const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file', { lastModified: 999 })]) + // expected no exception + expect(dir.children).toHaveLength(1) + expect(dir.lastModified).toBe(999) + }) + + it('Keeps its orginal name', () => { + // The conflict picker will force-overwrite the name attribute so we emulate this + const dir = new Directory('base') + expect(dir.name).toBe('base') + expect(dir.originalName).toBe('base') + + Object.defineProperty(dir, 'name', { value: 'other-base' }) + expect(dir.name).toBe('other-base') + expect(dir.originalName).toBe('base') + }) + + it('can add a child', async () => { + const dir = new Directory('base') + expect(dir.children).toHaveLength(0) + + await dir.addChild(new File(['a'.repeat(1024)], 'my-file')) + expect(dir.children).toHaveLength(1) + expect(dir.children[0].name).toBe('my-file') + }) + + it('can add a child to existing ones', async () => { + const dir = new Directory('base', [new File(['a'.repeat(1024)], 'my-file')]) + expect(dir.children).toHaveLength(1) + + await dir.addChild(new File(['a'.repeat(1024)], 'my-other-file')) + expect(dir.children).toHaveLength(2) + expect(dir.children[0].name).toBe('my-file') + expect(dir.children[1].name).toBe('my-other-file') + }) + + it('Can detect invalid children added', async () => { + const dir = new Directory('base/valid') + + await expect(() => dir.addChild(new File([], 'base/invalid/foo.txt'))).rejects.toThrowError(/File .+ is not a child of .+/) + }) + + it('Can get a child', async () => { + const dir = new Directory('base', [new File([], 'base/file')]) + + expect(dir.getChild('file')).toBeInstanceOf(File) + }) + + it('returns null if child is not found', async () => { + const dir = new Directory('base/valid') + + expect(dir.getChild('foo')).toBeNull() + }) + + it('Can add nested children', async () => { + const dir = new Directory('a') + dir.addChild(new File(['I am file D'], 'a/b/c/d.txt')) + + expect(dir.children).toHaveLength(1) + expect(dir.children[0]).toBeInstanceOf(Directory) + const dirB = dir.children[0] as Directory + expect(dirB.webkitRelativePath).toBe('a/b') + expect(dirB.name).toBe('b') + + expect(dirB.children).toHaveLength(1) + expect(dirB.children[0]).toBeInstanceOf(Directory) + const dirC = dirB.children[0] as Directory + expect(dirC.name).toBe('c') + expect(dirC.webkitRelativePath).toBe('a/b/c') + + expect(dirC.children).toHaveLength(1) + expect(await dirC.children[0].text()).toBe('I am file D') + }) + + it('Can add to existing nested children', async () => { + // First we start like the "can add nested" test + const dir = new Directory('a') + dir.addChild(new File(['I am file D'], 'a/b/c/d.txt')) + + // But now we add a second file + dir.addChild(new File(['I am file E'], 'a/b/e.txt')) + + expect(dir.children).toHaveLength(1) + expect(dir.children[0]).toBeInstanceOf(Directory) + const dirB = dir.children[0] as Directory + expect(dirB.webkitRelativePath).toBe('a/b') + expect(dirB.name).toBe('b') + + expect(dirB.children).toHaveLength(2) + expect(dirB.getChild('c')).toBeInstanceOf(Directory) + expect(dirB.getChild('e.txt')).toBeInstanceOf(File) + expect(await dirB.getChild('e.txt')!.text()).toBe('I am file E') + }) + + it('updates the stats when adding new children', async () => { + const dir = new Directory('base') + + expect(dir.size).toBe(0) + + await dir.addChild(new File(['a'.repeat(1024)], 'my-file', { lastModified: 999 })) + expect(dir.size).toBe(1024) + expect(dir.lastModified).toBe(999) + + await dir.addChild(new File(['a'.repeat(1024)], 'my-other-file', { lastModified: 8888 })) + expect(dir.size).toBe(2048) + expect(dir.lastModified).toBe(8888) + + await dir.addChild(new File(['a'.repeat(1024)], 'my-older-file', { lastModified: 500 })) + expect(dir.size).toBe(3072) + expect(dir.lastModified).toBe(8888) + }) +}) diff --git a/lib/utils/fileTree.ts b/lib/utils/fileTree.ts new file mode 100644 index 00000000..20cee5b0 --- /dev/null +++ b/lib/utils/fileTree.ts @@ -0,0 +1,95 @@ +/** + * Helpers to generate a file tree when the File and Directory API is used (e.g. Drag and Drop or ) + */ + +import { basename } from '@nextcloud/paths' +import { isFileSystemDirectoryEntry, isFileSystemFileEntry } from './filesystem.ts' + +/** + * This is a helper class to allow building a file tree for uploading + * It allows to create virtual directories + */ +export class Directory extends File { + + private _originalName: string + private _path: string + private _children: Map + + constructor(path: string, children?: Array) { + super([], basename(path), { type: 'httpd/unix-directory', lastModified: 0 }) + this._children = new Map() + this._originalName = basename(path) + this._path = path + + if (children) { + children.forEach((c) => this.addChild(c)) + } + } + + get size(): number { + return this.children.reduce((sum, file) => sum + file.size, 0) + } + + get lastModified(): number { + return this.children.reduce((latest, file) => Math.max(latest, file.lastModified), 0) + } + + // We need this to keep track of renamed files + get originalName(): string { + return this._originalName + } + + get children(): Array { + return Array.from(this._children.values()) + } + + get webkitRelativePath(): string { + return this._path + } + + getChild(name: string): File|Directory|null { + return this._children.get(name) ?? null + } + + async addChild(file: File|FileSystemEntry) { + const rootPath = this._path && `${this._path}/` + if (isFileSystemFileEntry(file)) { + file = await new Promise((resolve, reject) => (file as FileSystemFileEntry).file(resolve, reject)) + } else if (isFileSystemDirectoryEntry(file)) { + const reader = file.createReader() + const entries = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)) + this._children.set(file.name, new Directory(`${rootPath}${file.name}`, entries)) + return + } + + // Make Typescript calm - we ensured it is not a file system entry above. + file = file as File + + const filePath = file.webkitRelativePath ?? file.name + // Handle plain files + if (!filePath.includes('/')) { + // Direct child of the directory + this._children.set(file.name, file) + } else { + // Check if file is a child + if (!filePath.startsWith(this._path)) { + throw new Error(`File ${filePath} is not a child of ${this._path}`) + } + // If file is a child check if we need to nest it + const relPath = filePath.slice(rootPath.length) + const name = basename(relPath) + // It is a direct child + if (name === relPath) { + this._children.set(name, file) + } else { + const base = relPath.slice(0, relPath.indexOf('/')) + if (this._children.has(base)) { + (this._children.get(base) as Directory).addChild(file) + } else { + this._children.set(base, new Directory(`${rootPath}${base}`, [file])) + } + } + } + } + +} diff --git a/package-lock.json b/package-lock.json index 328d8f6d..3c6eb859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@types/node": "^20.11.30", "@vitest/coverage-v8": "^1.5.2", "@vue/tsconfig": "^0.5.1", + "blob-polyfill": "^7.0.20220408", "cypress": "^13.7.1", "cypress-file-upload": "^5.0.8", "gettext-extractor": "^3.8.0", diff --git a/vitest.config.ts b/vitest.config.ts index 3b01364f..672d0138 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default async (env) => { cfg.test = { environment: 'jsdom', + setupFiles: '__tests__/setup.ts', coverage: { include: ['lib/**'], // This makes no sense to test From 284a06aba19e810187bd3ca7b450c620a72d93a0 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 26 Apr 2024 18:52:51 +0200 Subject: [PATCH 2/4] feat(uploader): Allow to upload directories / allow bulk upload Signed-off-by: Ferdinand Thiessen --- lib/uploader.ts | 231 +++++++++++++++++++++++++++++++++++++++------- package-lock.json | 9 +- package.json | 4 +- 3 files changed, 207 insertions(+), 37 deletions(-) diff --git a/lib/uploader.ts b/lib/uploader.ts index bcb8f440..4a7ecc41 100644 --- a/lib/uploader.ts +++ b/lib/uploader.ts @@ -1,10 +1,12 @@ import type { AxiosError, AxiosResponse } from 'axios' +import type { WebDAVClient } from 'webdav' import { CanceledError } from 'axios' import { encodePath } from '@nextcloud/paths' -import { Folder, Permission } from '@nextcloud/files' +import { Folder, Permission, davGetClient } from '@nextcloud/files' import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { normalize } from 'path' import axios from '@nextcloud/axios' import PCancelable from 'p-cancelable' import PQueue from 'p-queue' @@ -12,6 +14,9 @@ import PQueue from 'p-queue' import { getChunk, initChunkWorkspace, uploadData } from './utils/upload.js' import { getMaxChunksSize } from './utils/config.js' import { Status as UploadStatus, Upload } from './upload.js' +import { isFileSystemFileEntry } from './utils/filesystem.js' +import { Directory } from './utils/fileTree.js' +import { t } from './utils/l10n.js' import logger from './utils/logger.js' export enum Status { @@ -63,6 +68,9 @@ export class Uploader { } this.destination = destinationFolder + // Reset when upload queue is done + this._jobQueue.addListener('idle', () => this.reset()) + logger.debug('Upload workspace initialized', { destination: this.destination, root: this.root, @@ -163,34 +171,199 @@ export class Uploader { this._notifiers.push(notifier) } + /** + * Notify listeners of the upload completion + * @param upload The upload that finished + */ + private _notifyAll(upload: Upload): void { + for (const notifier of this._notifiers) { + try { + notifier(upload) + } catch (error) { + logger.warn('Error in upload notifier', { error, source: upload.source }) + } + } + } + + /** + * Uploads multiple files or folders while preserving the relative path (if available) + * @param {string} destination The destination path relative to the root folder. e.g. /foo/bar (a file "a.txt" will be uploaded then to "/foo/bar/a.txt") + * @param {Array} files The files and/or folders to upload + * @param {Function} callback Callback that receives the nodes in the current folder and the current path to allow resolving conflicts, all nodes that are returned will be uploaded (if a folder does not exist it will be created) + * @return Cancelable promise that resolves to an array of uploads + * + * @example + * ```ts + * // For example this is from handling the onchange event of an input[type=file] + * async handleFiles(files: File[]) { + * this.uploads = await this.uploader.batchUpload('uploads', files, this.handleConflicts) + * } + * + * async handleConflicts(nodes: File[], currentPath: string) { + * const conflicts = getConflicts(nodes, this.fetchContent(currentPath)) + * if (conficts.length === 0) { + * // No conflicts so upload all + * return nodes + * } else { + * // Open the conflict picker to resolve conflicts + * try { + * const { selected, renamed } = await openConflictPicker(currentPath, conflicts, this.fetchContent(currentPath), { recursive: true }) + * return [...selected, ...renamed] + * } catch (e) { + * return false + * } + * } + * } + * ``` + */ + batchUpload( + destination: string, + files: (File|FileSystemEntry)[], + callback?: (nodes: Array, + currentPath: string) => Promise|false>, + ): PCancelable { + const rootFolder = new Directory('', files) + if (!callback) { + callback = async (files: Array) => files + } + + try { + // Increase concurrency to 4 to keep 3 parallel uploads as one if blocked by the directory meta-upload + this._jobQueue.concurrency += 1 + + return new PCancelable(async (resolve, reject, onCancel) => { + try { + const value = await this._jobQueue.add(() => { + const promise = this.uploadDirectory(destination, rootFolder, callback, davGetClient(this.root)) + onCancel(() => promise.cancel()) + return promise + }) + if (value) { + resolve(value) + } + } catch (error) { + logger.error('Error in batch upload', { error }) + } + reject(t('Upload has been cancelled')) + }) + } finally { + // Reset concurrency + this._jobQueue.concurrency -= 1 + } + } + + // Helper for uploading directories (recursivly) + private uploadDirectory( + destination: string, + directory: Directory, + callback: (nodes: Array, currentPath: string) => Promise|false>, + // client as parameter to cache it for performance + client: WebDAVClient, + ): PCancelable { + const folderPath = normalize(`${destination}/${directory.name}`).replace(/\/$/, '') + const rootPath = `${this.root.replace(/\/$/, '')}/${folderPath.replace(/^\//, '')}` + + return new PCancelable(async (resolve, reject, onCancel) => { + const abort = new AbortController() + onCancel(() => abort.abort()) + + // Let the user handle conflicts + const selectedForUpload = await callback(directory.children, folderPath) + if (selectedForUpload === false) { + reject(t('Upload has been cancelled')) + return + } + + const directories: PCancelable[] = [] + const uploads: PCancelable[] = [] + const currentUpload: Upload = new Upload(rootPath, false, 0, directory) + currentUpload.signal.addEventListener('abort', () => reject(t('Upload has been cancelled'))) + currentUpload.status = UploadStatus.UPLOADING + + try { + // Wait for own directory to be created (if not the virtual root) + if (directory.name) { + try { + await client.createDirectory(folderPath, { signal: abort.signal }) + // We add the "upload" to get some information of changed nodes + uploads.push(new PCancelable((resolve) => resolve(currentUpload!))) + this._uploadQueue.push(currentUpload) + } catch (error) { + if (error && typeof error === 'object' && 'status' in error && error.status === 405) { + // Directory already exists, so just write into it and ignore the error + logger.debug('Directory already exists, writing into it', { directory: directory.name }) + } else { + // Another error happend, so abort uploading the directory + throw error + } + } + } + + for (const node of selectedForUpload) { + if (node instanceof Directory) { + directories.push(this.uploadDirectory(folderPath, node, callback, client)) + } else { + uploads.push(this.upload(`${folderPath}/${node.name}`, node)) + } + } + + abort.signal.addEventListener('abort', () => { + uploads.forEach((upload) => upload.cancel()) + directories.forEach((upload) => upload.cancel()) + }) + + const resolvedUploads = await Promise.all(uploads) + const resolvedDirectoryUploads = await Promise.all(directories) + currentUpload.status = UploadStatus.FINISHED + resolve([resolvedUploads, ...resolvedDirectoryUploads].flat()) + } catch (e) { + abort.abort(e) + currentUpload.status = UploadStatus.FAILED + reject(e) + } finally { + if (directory.name) { + this._notifyAll(currentUpload) + this.updateStats() + } + } + }) + } + /** * Upload a file to the given path - * @param {string} destinationPath the destination path relative to the root folder. e.g. /foo/bar.txt - * @param {File} file the file to upload + * @param {string} destination the destination path relative to the root folder. e.g. /foo/bar.txt + * @param {File|FileSystemFileEntry} fileHandle the file to upload * @param {string} root the root folder to upload to */ - upload(destinationPath: string, file: File, root?: string): PCancelable { - const destinationFile = `${root || this.root}/${destinationPath.replace(/^\//, '')}` + upload(destination: string, fileHandle: File|FileSystemFileEntry, root?: string): PCancelable { + root = root || this.root + const destinationPath = `${root.replace(/\/$/, '')}/${destination.replace(/^\//, '')}` // Get the encoded source url to this object for requests purposes - const { origin } = new URL(destinationFile) - const encodedDestinationFile = origin + encodePath(destinationFile.slice(origin.length)) + const { origin } = new URL(destinationPath) + const encodedDestinationFile = origin + encodePath(destinationPath.slice(origin.length)) - logger.debug(`Uploading ${file.name} to ${encodedDestinationFile}`) + logger.debug(`Uploading ${fileHandle.name} to ${encodedDestinationFile}`) - // If manually disabled or if the file is too small - // TODO: support chunk uploading in public pages - const maxChunkSize = getMaxChunksSize(file.size) - const disabledChunkUpload = maxChunkSize === 0 - || file.size < maxChunkSize - || this._isPublic + const promise = new PCancelable(async (resolve, reject, onCancel): Promise => { + // Handle file system entries by retrieving the file handle + if (isFileSystemFileEntry(fileHandle)) { + fileHandle = await new Promise((resolve) => (fileHandle as FileSystemFileEntry).file(resolve, reject)) + } + // We can cast here as we handled system entries in the if above + const file = fileHandle as File - const upload = new Upload(destinationFile, !disabledChunkUpload, file.size, file) - this._uploadQueue.push(upload) - this.updateStats() + // If manually disabled or if the file is too small + // TODO: support chunk uploading in public pages + const maxChunkSize = getMaxChunksSize('size' in file ? file.size : undefined) + const disabledChunkUpload = this._isPublic + || maxChunkSize === 0 + || ('size' in file && file.size < maxChunkSize) + + const upload = new Upload(destinationPath, !disabledChunkUpload, file.size, file) + this._uploadQueue.push(upload) + this.updateStats() - // eslint-disable-next-line no-async-promise-executor - const promise = new PCancelable(async (resolve, reject, onCancel): Promise => { // Register cancellation caller onCancel(upload.cancel) @@ -271,7 +444,7 @@ export class Uploader { reject('Failed assembling the chunks together') } else { upload.status = UploadStatus.FAILED - reject('Upload has been cancelled') + reject(t('Upload has been cancelled')) } // Cleaning up temp directory @@ -282,11 +455,7 @@ export class Uploader { } // Notify listeners of the upload completion - this._notifiers.forEach(notifier => { - try { - notifier(upload) - } catch (error) {} - }) + this._notifyAll(upload) } else { logger.debug('Initializing regular upload', { file, upload }) @@ -319,7 +488,7 @@ export class Uploader { } catch (error) { if (error instanceof CanceledError) { upload.status = UploadStatus.FAILED - reject('Upload has been cancelled') + reject(t('Upload has been cancelled')) return } @@ -334,19 +503,11 @@ export class Uploader { } // Notify listeners of the upload completion - this._notifiers.forEach(notifier => { - try { - notifier(upload) - } catch (error) {} - }) + this._notifyAll(upload) } this._jobQueue.add(request) this.updateStats() } - - // Reset when upload queue is done - this._jobQueue.onIdle() - .then(() => this.reset()) return upload }) as PCancelable diff --git a/package-lock.json b/package-lock.json index 3c6eb859..a656af17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "typedoc": "^0.25.12", "typescript": "^5.4.3", "vitest": "^1.5.2", - "vue-material-design-icons": "^5.3.0" + "vue-material-design-icons": "^5.3.0", + "webdav": "^5.5.0" }, "engines": { "node": "^20.0.0", @@ -4935,6 +4936,12 @@ "node": ">=8" } }, + "node_modules/blob-polyfill": { + "version": "7.0.20220408", + "resolved": "https://registry.npmjs.org/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz", + "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==", + "dev": true + }, "node_modules/blob-util": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", diff --git a/package.json b/package.json index 9d1c858e..34b053aa 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@types/node": "^20.11.30", "@vitest/coverage-v8": "^1.5.2", "@vue/tsconfig": "^0.5.1", + "blob-polyfill": "^7.0.20220408", "cypress": "^13.7.1", "cypress-file-upload": "^5.0.8", "gettext-extractor": "^3.8.0", @@ -67,7 +68,8 @@ "typedoc": "^0.25.12", "typescript": "^5.4.3", "vitest": "^1.5.2", - "vue-material-design-icons": "^5.3.0" + "vue-material-design-icons": "^5.3.0", + "webdav": "^5.5.0" }, "dependencies": { "@nextcloud/auth": "^2.2.1", From 77ebbc4b7a4d181542126798f76203465b4909ff Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 29 Apr 2024 13:09:40 +0200 Subject: [PATCH 3/4] feat(UploadPicker): Allow to pick folders for upload Signed-off-by: Ferdinand Thiessen --- cypress/components/UploadPicker.cy.ts | 82 +++++++- l10n/messages.pot | 25 ++- lib/components/UploadPicker.vue | 274 +++++++++++++++++++------- lib/index.ts | 4 +- 4 files changed, 304 insertions(+), 81 deletions(-) diff --git a/cypress/components/UploadPicker.cy.ts b/cypress/components/UploadPicker.cy.ts index 38110166..eadcb25c 100644 --- a/cypress/components/UploadPicker.cy.ts +++ b/cypress/components/UploadPicker.cy.ts @@ -4,6 +4,7 @@ import { Folder, Permission, addNewFileMenuEntry, type Entry } from '@nextcloud/files' import { generateRemoteUrl } from '@nextcloud/router' import { UploadPicker, getUploader } from '../../lib/index.ts' +import { basename } from 'path' describe('UploadPicker rendering', () => { afterEach(() => { @@ -106,6 +107,10 @@ describe('UploadPicker valid uploads', () => { describe('UploadPicker invalid uploads', () => { + // Cypress shares the module state between tests, we need to reset it + // ref: https://github.com/cypress-io/cypress/issues/25441 + beforeEach(() => getUploader(true)) + afterEach(() => { // Make sure we clear the body cy.window().then((win) => { @@ -171,7 +176,82 @@ describe('UploadPicker invalid uploads', () => { cy.wait('@upload') // Should not have been called more than once as the first file is invalid cy.get('@upload.all').should('have.length', 1) - cy.get('body').should('contain', '"$" is not allowed inside a file name.') + cy.contains('[role="dialog"]', 'Invalid file name') + .should('be.visible') + }) + + it('Can rename invalid files', () => { + // Make sure we reset the destination + // so other tests do not interfere + const propsData = { + destination: new Folder({ + id: 56, + owner: 'user', + source: generateRemoteUrl('dav/files/user'), + permissions: Permission.ALL, + root: '/files/user', + }), + forbiddenCharacters: ['$', '#', '~', '&'], + } + + // Mount picker + cy.mount(UploadPicker, { propsData }).as('uploadPicker') + + // Label is displayed before upload + cy.get('[data-cy-upload-picker]').contains('New').should('be.visible') + + // Check and init aliases + cy.get('[data-cy-upload-picker] [data-cy-upload-picker-input]').as('input').should('exist') + cy.get('[data-cy-upload-picker] .upload-picker__progress').as('progress').should('exist') + + // Intercept single upload + cy.intercept('PUT', '/remote.php/dav/files/*/*', (req) => { + req.reply({ + statusCode: 201, + delay: 2000, + }) + }).as('upload') + + // Upload 2 files + cy.get('@input').attachFile({ + // Fake file of 5 MB + fileContent: new Blob([new ArrayBuffer(2 * 1024 * 1024)]), + fileName: 'invalid-image$.jpg', + mimeType: 'image/jpeg', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.get('@input').attachFile({ + // Fake file of 5 MB + fileContent: new Blob([new ArrayBuffer(2 * 1024 * 1024)]), + fileName: 'valid-image.jpg', + mimeType: 'image/jpeg', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.get('[data-cy-upload-picker] .upload-picker__progress') + .as('progress') + .should('not.be.visible') + + cy.contains('[role="dialog"]', 'Invalid file name') + .should('be.visible') + .contains('button', 'Rename') + .click() + + cy.wait('@upload') + // Should have been called two times with an valid name now + cy.get('@upload.all').should('have.length', 2).then((array): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requests = (array as unknown as any[]).map(({ request }) => basename(request.url)) + // The valid one is included + expect(requests).to.contain('valid-image.jpg') + // The invalid is NOT included + expect(requests).to.not.contain('invalid-image$.jpg') + // The invalid was made valid + expect(requests).to.contain('invalid-image-.jpg') + }) }) }) diff --git a/l10n/messages.pot b/l10n/messages.pot index 701d92ad..4753d7ff 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -2,6 +2,9 @@ msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" +msgid "\"{filename}\" contains invalid characters, how do you want to continue?" +msgstr "" + msgid "{count} file conflict" msgid_plural "{count} files conflict" msgstr[0] "" @@ -34,6 +37,9 @@ msgstr "" msgid "Continue" msgstr "" +msgid "Create new" +msgstr "" + msgid "estimating time left" msgstr "" @@ -43,6 +49,9 @@ msgstr "" msgid "If you select both versions, the incoming file will have a number added to its name." msgstr "" +msgid "Invalid file name" +msgstr "" + msgid "Last modified date unknown" msgstr "" @@ -58,6 +67,9 @@ msgstr "" msgid "Preview image" msgstr "" +msgid "Rename" +msgstr "" + msgid "Select all checkboxes" msgstr "" @@ -67,6 +79,9 @@ msgstr "" msgid "Select all new files" msgstr "" +msgid "Skip" +msgstr "" + msgid "Skip this file" msgid_plural "Skip {count} files" msgstr[0] "" @@ -75,10 +90,16 @@ msgstr[1] "" msgid "Unknown size" msgstr "" -msgid "Upload cancelled" +msgid "Upload files" msgstr "" -msgid "Upload files" +msgid "Upload folders" +msgstr "" + +msgid "Upload from device" +msgstr "" + +msgid "Upload has been cancelled" msgstr "" msgid "Upload progress" diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index 08f70d36..112464ec 100644 --- a/lib/components/UploadPicker.vue +++ b/lib/components/UploadPicker.vue @@ -9,25 +9,41 @@ :disabled="disabled" data-cy-upload-picker-add type="secondary" - @click="onClick"> + @click="onTriggerPick()"> {{ buttonName }} - + + + + - {{ uploadLabel }} + {{ t('Upload files') }} + + + {{ t('Upload folders') }} + + + + + + @click="entry.handler(destination, currentContent)"> @@ -45,7 +61,7 @@
- - @@ -82,40 +97,49 @@