Signed-off-by: skjnldsv <skjnldsv@protonmail.com>pull/44409/head
parent
aacc7abccc
commit
38c7ca0d4d
@ -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 [] |
||||
} |
@ -1,59 +0,0 @@ |
||||
import { basename } from 'node:path' |
||||
|
||||
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 |
||||
|
||||
constructor(fullPath: string, contents: string) { |
||||
super(true, fullPath) |
||||
this._contents = contents |
||||
} |
||||
|
||||
file(success: (file: File) => void) { |
||||
success(new File([this._contents], this.name)) |
||||
} |
||||
|
||||
} |
||||
|
||||
export class FileSystemDirectoryEntry extends FileSystemEntry { |
||||
|
||||
private _entries: FileSystemEntry[] |
||||
|
||||
constructor(fullPath: string, entries: FileSystemEntry[]) { |
||||
super(false, fullPath) |
||||
this._entries = entries || [] |
||||
} |
||||
|
||||
createReader() { |
||||
return { |
||||
readEntries: (success: (entries: FileSystemEntry[]) => void) => { |
||||
success(this._entries) |
||||
}, |
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue