Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/files/src/components/NewNodeDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
variant="primary"
:disabled="validity !== ''"
@click="submit">
{{ t('files', 'Create') }}
{{ submitLabel }}
</NcButton>
</template>
<form
Expand Down Expand Up @@ -90,6 +90,14 @@ const props = defineProps({
default: t('files', 'Folder name'),
},

/**
* Label of the submit button
*/
submitLabel: {
type: String,
default: t('files', 'Create'),
},

/**
* Whether the name is for a folder, which affects the validation of the name. Defaults to false.
*/
Expand Down
79 changes: 79 additions & 0 deletions apps/files/src/services/DropService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { IFolder } from '@nextcloud/files'
import type { RootDirectory } from './DropServiceUtils.ts'

import { showError, showInfo, showSuccess } from '@nextcloud/dialogs'
import { getUploader, hasConflict } from '@nextcloud/upload'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { newNodeName } from '../utils/newNodeDialog.ts'
import { onDropExternalFiles } from './DropService.ts'
import { createDirectoryIfNotExists, Directory } from './DropServiceUtils.ts'

vi.mock('@nextcloud/dialogs')
vi.mock('@nextcloud/upload', () => ({
getUploader: vi.fn(),
hasConflict: vi.fn(),
}))
vi.mock('../utils/newNodeDialog.ts')
vi.mock('./DropServiceUtils.ts', async (importOriginal) => ({
...await importOriginal(),
createDirectoryIfNotExists: vi.fn(),
}))
vi.mock('@nextcloud/capabilities', () => ({
getCapabilities: () => ({
files: {
forbidden_filename_characters: ['/', '\\'],
forbidden_filenames: ['.htaccess'],
forbidden_filename_basenames: [],
forbidden_filename_extensions: ['.part'],
},
}),
}))

describe('onDropExternalFiles', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getUploader).mockReturnValue({
pause: vi.fn(),
start: vi.fn(),
} as never)
vi.mocked(hasConflict).mockReturnValue(false)
})

it('asks to rename an invalid entry before starting the upload', async () => {
const root = new Directory('root', [new Directory('test\\')]) as RootDirectory
vi.mocked(newNodeName).mockResolvedValue('test')

await onDropExternalFiles(root, {} as IFolder, [])

expect(newNodeName).toHaveBeenCalledWith('test\\', [], expect.objectContaining({ isFolder: true }))
expect(root.contents[0].name).toBe('test')
})

it('aborts the upload if the rename is cancelled', async () => {
const root = new Directory('root', [new Directory('test\\')]) as RootDirectory
vi.mocked(newNodeName).mockResolvedValue(null)

const uploads = await onDropExternalFiles(root, {} as IFolder, [])

expect(uploads).toEqual([])
expect(showInfo).toHaveBeenCalledWith('Upload cancelled, drop the files again to retry')
expect(getUploader).not.toHaveBeenCalled()
expect(hasConflict).not.toHaveBeenCalled()
})

it('does not report success after a directory creation failure', async () => {
const root = new Directory('root', [new Directory('folder')]) as RootDirectory
vi.mocked(createDirectoryIfNotExists).mockRejectedValue(new Error('Failed to create directory'))

const uploads = await onDropExternalFiles(root, {} as IFolder, [])

expect(uploads).toEqual([])
expect(showError).toHaveBeenCalledWith('Unable to create the directory folder')
expect(showSuccess).not.toHaveBeenCalled()
})
})
12 changes: 11 additions & 1 deletion apps/files/src/services/DropService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getUploader, hasConflict } from '@nextcloud/upload'
import { handleCopyMoveNodesTo, HintException } from '../actions/moveOrCopyAction.ts'
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { logger } from '../utils/logger.ts'
import { createDirectoryIfNotExists, Directory, resolveConflict, traverseTree } from './DropServiceUtils.ts'
import { createDirectoryIfNotExists, Directory, renameInvalidDroppedEntries, resolveConflict, traverseTree } from './DropServiceUtils.ts'

/**
* This function converts a list of DataTransferItems to a file tree.
Expand Down Expand Up @@ -94,6 +94,12 @@ export async function dataTransferToFileTree(items: DataTransferItem[]): Promise
* @param contents - The contents of the destination folder
*/
export async function onDropExternalFiles(root: RootDirectory, destination: IFolder, contents: INode[]): Promise<Upload[]> {
// Ask the user to fix any invalid name before uploading anything
if (!await renameInvalidDroppedEntries(root)) {
showInfo(t('files', 'Upload cancelled, drop the files again to retry'))
return []
Comment thread
hamza221 marked this conversation as resolved.
}

const uploader = getUploader()

// Check for conflicts on root elements
Expand All @@ -112,6 +118,7 @@ export async function onDropExternalFiles(root: RootDirectory, destination: IFol
// Let's process the files
logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
const queue = [] as Promise<Upload>[]
let hasDirectoryErrors = false

const uploadDirectoryContents = async (directory: Directory, path: string) => {
for (const file of directory.contents) {
Expand All @@ -127,6 +134,7 @@ export async function onDropExternalFiles(root: RootDirectory, destination: IFol
await createDirectoryIfNotExists(relativePath, destination)
await uploadDirectoryContents(file, relativePath)
} catch (error) {
hasDirectoryErrors = true
showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
logger.error('Unable to create the directory', { error, relativePath, directory: file })
}
Expand Down Expand Up @@ -158,6 +166,8 @@ export async function onDropExternalFiles(root: RootDirectory, destination: IFol
if (errors.length > 0) {
logger.error('Error while uploading files', { errors })
showError(t('files', 'Some files could not be uploaded'))
}
if (errors.length > 0 || hasDirectoryErrors) {
return []
}

Expand Down
82 changes: 80 additions & 2 deletions apps/files/src/services/DropServiceUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@
*/

import { join } from 'node:path'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DataTransferItem as DataTransferItemMock, FileSystemDirectoryEntry, fileSystemEntryToDataTransferItem, FileSystemFileEntry } from '../../../../__tests__/FileSystemAPIUtils.ts'
import { logger } from '../utils/logger.ts'
import { newNodeName } from '../utils/newNodeDialog.ts'
import { dataTransferToFileTree } from './DropService.ts'
import { Directory, traverseTree } from './DropServiceUtils.ts'
import { Directory, renameInvalidDroppedEntries, traverseTree } from './DropServiceUtils.ts'

vi.mock('@nextcloud/dialogs')
vi.mock('../utils/newNodeDialog.ts')
vi.mock('@nextcloud/capabilities', () => ({
getCapabilities: () => ({
files: {
forbidden_filename_characters: ['/', '\\'],
forbidden_filenames: ['.htaccess'],
forbidden_filename_basenames: [],
forbidden_filename_extensions: ['.part', ' '],
},
}),
}))

const dataTree = {
'file0.txt': ['Hello, world!', 1234567890],
Expand Down Expand Up @@ -87,6 +99,72 @@ describe('Filesystem API traverseTree', () => {
})
})

describe('renameInvalidDroppedEntries', () => {
beforeEach(() => vi.mocked(newNodeName).mockReset())

it('does not prompt for a valid tree', async () => {
const tree = new Directory('root', [
new Directory('folder', [new File([], 'file.txt')]),
])

expect(await renameInvalidDroppedEntries(tree)).toBe(true)
expect(newNodeName).not.toHaveBeenCalled()
})

it('renames an invalid top-level folder and keeps its contents', async () => {
const tree = new Directory('root', [new Directory('folder\\', [new File([], 'file.txt')])])
vi.mocked(newNodeName).mockResolvedValue('folder')

expect(await renameInvalidDroppedEntries(tree)).toBe(true)
expect(newNodeName).toHaveBeenCalledWith('folder\\', [], expect.objectContaining({ isFolder: true }))
expect(tree.contents[0].name).toBe('folder')
expect((tree.contents[0] as Directory).contents[0].name).toBe('file.txt')
})

it('renames an invalid nested file and keeps its content', async () => {
const tree = new Directory('root', [
new Directory('folder', [new File(['content'], 'file\\.txt', { type: 'text/plain' })]),
])
vi.mocked(newNodeName).mockResolvedValue('file.txt')

expect(await renameInvalidDroppedEntries(tree)).toBe(true)
expect(newNodeName).toHaveBeenCalledWith('file\\.txt', [], expect.objectContaining({ isFolder: false }))

const file = (tree.contents[0] as Directory).contents[0]
expect(file.name).toBe('file.txt')
expect(file.type).toBe('text/plain')
expect(await file.text()).toBe('content')
})

it('keeps the new name unique within the dropped folder', async () => {
const tree = new Directory('root', [
new File([], 'file.txt'),
new File([], 'file\\.txt'),
])
vi.mocked(newNodeName).mockResolvedValue('file.txt')

expect(await renameInvalidDroppedEntries(tree)).toBe(true)
expect(newNodeName).toHaveBeenCalledWith('file\\.txt', ['file.txt'], expect.anything())
expect(tree.contents[1].name).toBe('file (1).txt')
})

it('trims the name returned by the dialog', async () => {
const tree = new Directory('root', [new Directory('folder\\')])
vi.mocked(newNodeName).mockResolvedValue(' folder ')

expect(await renameInvalidDroppedEntries(tree)).toBe(true)
expect(tree.contents[0].name).toBe('folder')
})

it('aborts when the user cancels the rename', async () => {
const tree = new Directory('root', [new Directory('folder\\')])
vi.mocked(newNodeName).mockResolvedValue(null)

expect(await renameInvalidDroppedEntries(tree)).toBe(false)
expect(tree.contents[0].name).toBe('folder\\')
})
})

describe('DropService dataTransferToFileTree', () => {
beforeAll(() => {
// @ts-expect-error jsdom doesn't have DataTransferItem
Expand Down
46 changes: 46 additions & 0 deletions apps/files/src/services/DropServiceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import type { FileStat, ResponseDataDetailed } from 'webdav'

import { showInfo, showWarning } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { getUniqueName } from '@nextcloud/files'
import { defaultRemoteURL, defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
import { t } from '@nextcloud/l10n'
import { join } from '@nextcloud/paths'
import { openConflictPicker } from '@nextcloud/upload'
import { getFilenameValidity } from '../utils/filenameValidity.ts'
import { logger } from '../utils/logger.ts'
import { newNodeName } from '../utils/newNodeDialog.ts'

/**
* This represents a Directory in the file tree
Expand Down Expand Up @@ -84,6 +87,49 @@ export type RootDirectory = Directory & {
name: 'root'
}

/**
* Ask the user to rename every invalid file or folder of a dropped file tree.
* Entries are replaced instead of renamed, as `File.name` is read only.
*
* @param directory Directory to validate
* @param path Path of the directory relative to the upload destination
* @return false if the user aborted the rename
*/
export async function renameInvalidDroppedEntries(directory: Directory, path = ''): Promise<boolean> {
Comment thread
pringelmann marked this conversation as resolved.
for (const [index, entry] of directory.contents.entries()) {
const isFolder = entry instanceof Directory
let node = entry

if (getFilenameValidity(node.name, false, isFolder) !== '') {
const otherNames = directory.contents.filter((other) => other !== node).map((other) => other.name)
const name = await newNodeName(node.name, otherNames, {
Comment thread
hamza221 marked this conversation as resolved.
name: t('files', 'Invalid name for "{path}"', { path: join(path, node.name) }, { escape: false }),
label: isFolder ? t('files', 'Folder name') : t('files', 'Filename'),
submitLabel: t('files', 'Rename'),
isFolder,
})

if (name === null) {
logger.debug('Upload cancelled while renaming an invalid entry', { path, name: node.name })
return false
}

// Keep the name unique within the dropped folder
const uniqueName = getUniqueName(name.trim(), otherNames)
node = isFolder
? new Directory(uniqueName, (node as Directory).contents)
: new File([node], uniqueName, { type: node.type, lastModified: node.lastModified })
directory.contents.splice(index, 1, node)
}

if (node instanceof Directory && !await renameInvalidDroppedEntries(node, join(path, node.name))) {
return false
}
}

return true
}

/**
* Traverse a file tree using the Filesystem API
*
Expand Down
11 changes: 8 additions & 3 deletions apps/files/src/utils/newNodeDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ interface NewNodeDialogOptions {
*/
label?: string

/**
* Label for the submit button, defaults to "Create"
*/
submitLabel?: string

/**
* Whether the name is for a folder, defaults to false.
*/
Expand All @@ -28,12 +33,12 @@ interface NewNodeDialogOptions {
* Ask user for file or folder name
*
* @param defaultName Default name to use
* @param folderContent Nodes with in the current folder to check for unique name
* @param folderContent Nodes or names within the current folder to check for unique name
* @param options Options for the dialog
* @return string if successful otherwise null if aborted
*/
export function newNodeName(defaultName: string, folderContent: INode[], options: NewNodeDialogOptions = {}) {
const contentNames = folderContent.map((node: INode) => node.basename)
export function newNodeName(defaultName: string, folderContent: (INode | string)[], options: NewNodeDialogOptions = {}) {
const contentNames = folderContent.map((node) => typeof node === 'string' ? node : node.basename)

return new Promise<string | null>((resolve) => {
spawnDialog(NewNodeDialog, {
Expand Down
Loading