mirror of https://github.com/nextcloud/server.git
Merge pull request #44409 from nextcloud/fix/files-dnd-files
commit
32e86052d5
@ -0,0 +1,124 @@
|
||||
import { basename } from 'node:path'
|
||||
import mime from 'mime'
|
||||
|
||||
class FileSystemEntry {
|
||||
|
||||
private _isFile: boolean
|
||||
private _fullPath: string
|
||||
|
||||
constructor(isFile: boolean, fullPath: string) {
|
||||
this._isFile = isFile
|
||||
this._fullPath = fullPath
|
||||
}
|
||||
|
||||
get isFile() {
|
||||
return !!this._isFile
|
||||
}
|
||||
|
||||
get isDirectory() {
|
||||
return !this.isFile
|
||||
}
|
||||
|
||||
get name() {
|
||||
return basename(this._fullPath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class FileSystemFileEntry extends FileSystemEntry {
|
||||
|
||||
private _contents: string
|
||||
private _lastModified: number
|
||||
|
||||
constructor(fullPath: string, contents: string, lastModified = Date.now()) {
|
||||
super(true, fullPath)
|
||||
this._contents = contents
|
||||
this._lastModified = lastModified
|
||||
}
|
||||
|
||||
file(success: (file: File) => void) {
|
||||
const lastModified = this._lastModified
|
||||
// Faking the mime by using the file extension
|
||||
const type = mime.getType(this.name) || ''
|
||||
success(new File([this._contents], this.name, { lastModified, type }))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class FileSystemDirectoryEntry extends FileSystemEntry {
|
||||
|
||||
private _entries: FileSystemEntry[]
|
||||
|
||||
constructor(fullPath: string, entries: FileSystemEntry[]) {
|
||||
super(false, fullPath)
|
||||
this._entries = entries || []
|
||||
}
|
||||
|
||||
createReader() {
|
||||
let read = false
|
||||
return {
|
||||
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
|
||||
if (read) {
|
||||
return success([])
|
||||
}
|
||||
read = true
|
||||
success(this._entries)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This mocks the File API's File class
|
||||
* It will allow us to test the Filesystem API as well as the
|
||||
* File API in the same test suite.
|
||||
*/
|
||||
export class DataTransferItem {
|
||||
|
||||
private _type: string
|
||||
private _entry: FileSystemEntry
|
||||
|
||||
getAsEntry?: () => FileSystemEntry
|
||||
|
||||
constructor(type = '', entry: FileSystemEntry, isFileSystemAPIAvailable = true) {
|
||||
this._type = type
|
||||
this._entry = entry
|
||||
|
||||
// Only when the Files API is available we are
|
||||
// able to get the entry
|
||||
if (isFileSystemAPIAvailable) {
|
||||
this.getAsEntry = () => this._entry
|
||||
}
|
||||
}
|
||||
|
||||
get kind() {
|
||||
return 'file'
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this._type
|
||||
}
|
||||
|
||||
getAsFile(): File|null {
|
||||
if (this._entry.isFile && this._entry instanceof FileSystemFileEntry) {
|
||||
let file: File | null = null
|
||||
this._entry.file((f) => {
|
||||
file = f
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
// The browser will return an empty File object if the entry is a directory
|
||||
return new File([], this._entry.name, { type: '' })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const fileSystemEntryToDataTransferItem = (entry: FileSystemEntry, isFileSystemAPIAvailable = true): DataTransferItem => {
|
||||
return new DataTransferItem(
|
||||
entry.isFile ? 'text/plain' : 'httpd/unix-directory',
|
||||
entry,
|
||||
isFileSystemAPIAvailable,
|
||||
)
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from '@jest/globals'
|
||||
|
||||
import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils'
|
||||
import { join } from 'node:path'
|
||||
import { Directory, traverseTree } from './DropServiceUtils'
|
||||
import { dataTransferToFileTree } from './DropService'
|
||||
import logger from '../logger'
|
||||
|
||||
const dataTree = {
|
||||
'file0.txt': ['Hello, world!', 1234567890],
|
||||
dir1: {
|
||||
'file1.txt': ['Hello, world!', 4567891230],
|
||||
'file2.txt': ['Hello, world!', 7891234560],
|
||||
},
|
||||
dir2: {
|
||||
'file3.txt': ['Hello, world!', 1234567890],
|
||||
},
|
||||
}
|
||||
|
||||
// This is mocking a file tree using the FileSystem API
|
||||
const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => {
|
||||
const entries = Object.entries(tree).map(([name, contents]) => {
|
||||
const fullPath = join(path, name)
|
||||
if (Array.isArray(contents)) {
|
||||
return new FileSystemFileEntry(fullPath, contents[0], contents[1])
|
||||
} else {
|
||||
return buildFileSystemDirectoryEntry(fullPath, contents)
|
||||
}
|
||||
})
|
||||
return new FileSystemDirectoryEntry(path, entries)
|
||||
}
|
||||
|
||||
const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => {
|
||||
return Object.entries(tree).map(([name, contents]) => {
|
||||
const fullPath = join(path, name)
|
||||
if (Array.isArray(contents)) {
|
||||
const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1])
|
||||
return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
|
||||
}
|
||||
|
||||
const entry = buildFileSystemDirectoryEntry(fullPath, contents)
|
||||
return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
|
||||
})
|
||||
}
|
||||
|
||||
describe('Filesystem API traverseTree', () => {
|
||||
it('Should traverse a file tree from root', async () => {
|
||||
// Fake a FileSystemEntry tree
|
||||
const root = buildFileSystemDirectoryEntry('root', dataTree)
|
||||
const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory
|
||||
|
||||
expect(tree.name).toBe('root')
|
||||
expect(tree).toBeInstanceOf(Directory)
|
||||
expect(tree.contents).toHaveLength(3)
|
||||
expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!'
|
||||
})
|
||||
|
||||
it('Should traverse a file tree from a subdirectory', async () => {
|
||||
// Fake a FileSystemEntry tree
|
||||
const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2)
|
||||
const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
|
||||
|
||||
expect(tree.name).toBe('dir2')
|
||||
expect(tree).toBeInstanceOf(Directory)
|
||||
expect(tree.contents).toHaveLength(1)
|
||||
expect(tree.contents[0].name).toBe('file3.txt')
|
||||
expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!'
|
||||
})
|
||||
|
||||
it('Should properly compute the last modified', async () => {
|
||||
// Fake a FileSystemEntry tree
|
||||
const root = buildFileSystemDirectoryEntry('root', dataTree)
|
||||
const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory
|
||||
|
||||
expect(rootTree.lastModified).toBe(7891234560)
|
||||
|
||||
// Fake a FileSystemEntry tree
|
||||
const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2)
|
||||
const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
|
||||
expect(dir2Tree.lastModified).toBe(1234567890)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropService dataTransferToFileTree', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
// DataTransferItem doesn't exists in jsdom, let's mock
|
||||
// a dumb one so we can check the instanceof
|
||||
// @ts-expect-error jsdom doesn't have DataTransferItem
|
||||
window.DataTransferItem = DataTransferItemMock
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// @ts-expect-error jsdom doesn't have DataTransferItem
|
||||
delete window.DataTransferItem
|
||||
})
|
||||
|
||||
it('Should return a RootDirectory with Filesystem API', async () => {
|
||||
jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
|
||||
|
||||
const dataTransferItems = buildDataTransferItemArray('root', dataTree)
|
||||
const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
|
||||
|
||||
expect(fileTree.name).toBe('root')
|
||||
expect(fileTree).toBeInstanceOf(Directory)
|
||||
expect(fileTree.contents).toHaveLength(3)
|
||||
|
||||
// The file tree should be recursive when using the Filesystem API
|
||||
expect(fileTree.contents[1]).toBeInstanceOf(Directory)
|
||||
expect((fileTree.contents[1] as Directory).contents).toHaveLength(2)
|
||||
expect(fileTree.contents[2]).toBeInstanceOf(Directory)
|
||||
expect((fileTree.contents[2] as Directory).contents).toHaveLength(1)
|
||||
|
||||
expect(logger.error).not.toBeCalled()
|
||||
expect(logger.warn).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => {
|
||||
jest.spyOn(logger, 'error').mockImplementation(() => jest.fn())
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn())
|
||||
|
||||
const dataTransferItems = buildDataTransferItemArray('root', dataTree, false)
|
||||
|
||||
const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
|
||||
|
||||
expect(fileTree.name).toBe('root')
|
||||
expect(fileTree).toBeInstanceOf(Directory)
|
||||
expect(fileTree.contents).toHaveLength(1)
|
||||
|
||||
// The file tree should be recursive when using the Filesystem API
|
||||
expect(fileTree.contents[0]).not.toBeInstanceOf(Directory)
|
||||
expect((fileTree.contents[0].name)).toBe('file0.txt')
|
||||
|
||||
expect(logger.error).not.toBeCalled()
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file')
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file')
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded')
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file')
|
||||
expect(logger.warn).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
import type { FileStat, ResponseDataDetailed } from 'webdav'
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files'
|
||||
import { openConflictPicker } from '@nextcloud/upload'
|
||||
import { showError, showInfo } from '@nextcloud/dialogs'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
import logger from '../logger.js'
|
||||
|
||||
/**
|
||||
* This represents a Directory in the file tree
|
||||
* We extend the File class to better handling uploading
|
||||
* and stay as close as possible as the Filesystem API.
|
||||
* This also allow us to hijack the size or lastModified
|
||||
* properties to compute them dynamically.
|
||||
*/
|
||||
export class Directory extends File {
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
_contents: (Directory|File)[]
|
||||
|
||||
constructor(name, contents: (Directory|File)[] = []) {
|
||||
super([], name, { type: 'httpd/unix-directory' })
|
||||
this._contents = contents
|
||||
}
|
||||
|
||||
set contents(contents: (Directory|File)[]) {
|
||||
this._contents = contents
|
||||
}
|
||||
|
||||
get contents(): (Directory|File)[] {
|
||||
return this._contents
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._computeDirectorySize(this)
|
||||
}
|
||||
|
||||
get lastModified() {
|
||||
if (this._contents.length === 0) {
|
||||
return Date.now()
|
||||
}
|
||||
return this._computeDirectoryMtime(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last modification time of a file tree
|
||||
* This is not perfect, but will get us a pretty good approximation
|
||||
* @param directory the directory to traverse
|
||||
*/
|
||||
_computeDirectoryMtime(directory: Directory): number {
|
||||
return directory.contents.reduce((acc, file) => {
|
||||
return file.lastModified > acc
|
||||
// If the file is a directory, the lastModified will
|
||||
// also return the results of its _computeDirectoryMtime method
|
||||
// Fancy recursion, huh?
|
||||
? file.lastModified
|
||||
: acc
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a file tree
|
||||
* @param directory the directory to traverse
|
||||
*/
|
||||
_computeDirectorySize(directory: Directory): number {
|
||||
return directory.contents.reduce((acc: number, entry: Directory|File) => {
|
||||
// If the file is a directory, the size will
|
||||
// also return the results of its _computeDirectorySize method
|
||||
// Fancy recursion, huh?
|
||||
return acc + entry.size
|
||||
}, 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type RootDirectory = Directory & {
|
||||
name: 'root'
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse a file tree using the Filesystem API
|
||||
* @param entry the entry to traverse
|
||||
*/
|
||||
export const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => {
|
||||
// Handle file
|
||||
if (entry.isFile) {
|
||||
return new Promise<File>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle directory
|
||||
logger.debug('Handling recursive file tree', { entry: entry.name })
|
||||
const directory = entry as FileSystemDirectoryEntry
|
||||
const entries = await readDirectory(directory)
|
||||
const contents = (await Promise.all(entries.map(traverseTree))).flat()
|
||||
return new Directory(directory.name, contents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a directory using Filesystem API
|
||||
* @param directory the directory to read
|
||||
*/
|
||||
const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
|
||||
const dirReader = directory.createReader()
|
||||
|
||||
return new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
const entries = [] as FileSystemEntry[]
|
||||
const getEntries = () => {
|
||||
dirReader.readEntries((results) => {
|
||||
if (results.length) {
|
||||
entries.push(...results)
|
||||
getEntries()
|
||||
} else {
|
||||
resolve(entries)
|
||||
}
|
||||
}, (error) => {
|
||||
reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
getEntries()
|
||||
})
|
||||
}
|
||||
|
||||
export const createDirectoryIfNotExists = async (absolutePath: string) => {
|
||||
const davClient = davGetClient()
|
||||
const dirExists = await davClient.exists(absolutePath)
|
||||
if (!dirExists) {
|
||||
logger.debug('Directory does not exist, creating it', { absolutePath })
|
||||
await davClient.createDirectory(absolutePath, { recursive: true })
|
||||
const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
|
||||
emit('files:node:created', davResultToNode(stat.data))
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => {
|
||||
try {
|
||||
// List all conflicting files
|
||||
const conflicts = files.filter((file: File|Node) => {
|
||||
return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
|
||||
}).filter(Boolean) as (File|Node)[]
|
||||
|
||||
// List of incoming files that are NOT in conflict
|
||||
const uploads = files.filter((file: File|Node) => {
|
||||
return !conflicts.includes(file)
|
||||
})
|
||||
|
||||
// Let the user choose what to do with the conflicting files
|
||||
const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
|
||||
|
||||
logger.debug('Conflict resolution', { uploads, selected, renamed })
|
||||
|
||||
// If the user selected nothing, we cancel the upload
|
||||
if (selected.length === 0 && renamed.length === 0) {
|
||||
// User skipped
|
||||
showInfo(t('files', 'Conflicts resolution skipped'))
|
||||
logger.info('User skipped the conflict resolution')
|
||||
return []
|
||||
}
|
||||
|
||||
// Update the list of files to upload
|
||||
return [...uploads, ...selected, ...renamed] as (typeof files)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// User cancelled
|
||||
showError(t('files', 'Upload cancelled'))
|
||||
logger.error('User cancelled the upload')
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,9 +0,0 @@
|
||||
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
|
||||
|
||||
//! license : MIT
|
||||
|
||||
//! moment.js
|
||||
|
||||
//! momentjs.com
|
||||
|
||||
//! version : 2.29.4
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue