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/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/ConflictPicker.vue b/lib/components/ConflictPicker.vue index 3cf52f7d..809e4a63 100644 --- a/lib/components/ConflictPicker.vue +++ b/lib/components/ConflictPicker.vue @@ -101,7 +101,6 @@ import type { Node } from '@nextcloud/files' import type { PropType } from 'vue' import type { ConflictResolutionResult } from '../index.ts' -import { basename, extname } from 'path' import { defineComponent } from 'vue' import { showError } from '@nextcloud/dialogs' @@ -112,6 +111,7 @@ import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import { isFileSystemEntry } from '../utils/filesystem.ts' +import { getUniqueName } from '../utils/uniqueName.ts' import { n, t } from '../utils/l10n.ts' import logger from '../utils/logger.ts' import NodesPicker from './NodesPicker.vue' @@ -285,7 +285,7 @@ export default defineComponent({ this.$emit('submit', { selected: [], renamed: [], - } as ConflictResolutionResult) + } as ConflictResolutionResult) }, onSubmit() { @@ -313,7 +313,7 @@ export default defineComponent({ if (toRename.length > 0) { toRename.forEach(file => { const name = (file instanceof File || isFileSystemEntry(file)) ? file.name : file.basename - const newName = this.getUniqueName(name, directoryContent) + const newName = getUniqueName(name, directoryContent) // If File, create a new one with the new name if (file instanceof File || isFileSystemEntry(file)) { // Keep the original file object and force rename @@ -340,25 +340,7 @@ export default defineComponent({ this.$emit('submit', { selected, renamed, - } as ConflictResolutionResult) - }, - - /** - * Get a unique name for a file based - * on the existing directory content. - * @param {string} name The original file name with extension - * @param {string} names The existing directory content names - * @return {string} A unique name - * TODO: migrate to @nextcloud/files - */ - getUniqueName(name: string, names: string[]): string { - let newName = name - let i = 1 - while (names.includes(newName)) { - const ext = extname(name) - newName = `${basename(name, ext)} (${i++})${ext}` - } - return newName + } as ConflictResolutionResult) }, /** diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index 08f70d36..d9e312af 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,50 @@