Skip to content

Commit

Permalink
feat(uploader): Allow to upload directories / allow bulk upload
Browse files Browse the repository at this point in the history
Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Apr 28, 2024
1 parent 74888f7 commit efb5024
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 21 deletions.
144 changes: 125 additions & 19 deletions lib/uploader.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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 axios from '@nextcloud/axios'
Expand All @@ -12,6 +13,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 {
Expand All @@ -34,6 +38,7 @@ export class Uploader {
private _queueStatus: Status = Status.IDLE

private _notifiers: Array<(upload: Upload) => void> = []
private __client: WebDAVClient

/**
* Initialize uploader
Expand Down Expand Up @@ -71,6 +76,13 @@ export class Uploader {
})
}

private get _client() {
if (!this.__client) {
this.__client = davGetClient()
}
return this.__client
}

/**
* Get the upload destination path relative to the root folder
*/
Expand Down Expand Up @@ -163,34 +175,128 @@ export class Uploader {
this._notifiers.push(notifier)
}

/**
* 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<File|FileSystemEntry>} files The files and/or folders to upload
* @param {string} root The root folder to upload to
* @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
* @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.bulkUpload('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
* }
* }
* }
* ```
*/
bulkUpload(destination: string, files: (File|FileSystemEntry)[], root?: string, callback?: (nodes: Array<File|Directory>, currentPath: string) => Promise<Array<File|Directory>|false>): PCancelable<Upload[]> {
const rootFolder = new Directory('', files)
if (!callback) {
callback = async (files: Array<File|Directory>) => files
}

return this.uploadDirectory(destination, rootFolder, callback, root)
}

// Helper for uploading directories (recursivly)
private uploadDirectory(destination: string, directory: Directory, callback: (nodes: Array<File|Directory>, currentPath: string) => Promise<Array<File|Directory>|false>, root?: string): PCancelable<Upload[]> {
const folderPath = `${destination.replace(/\/$/, '')}/${directory.name}`
const destinationPath = `${root || this.root}/${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 cancelled by user'))
return
}

// Wait for own directory to be created (if not the virtual root)
if (directory.name) {
await this._client.createDirectory(destinationPath, { signal: abort.signal })
}

const directories: PCancelable<Upload[]>[] = []
const uploads: PCancelable<Upload>[] = []
for (const node of selectedForUpload) {
if (node instanceof Directory) {
directories.push(this.uploadDirectory(folderPath, node, callback, root))
} else {
uploads.push(this.upload(folderPath, node, root))
}
}

abort.signal.addEventListener('abort', () => {
uploads.forEach((upload) => upload.cancel())
directories.forEach((upload) => upload.cancel())
})

try {
const resolvedUploads = await Promise.all(uploads)
const resolvedDirectoryUploads = await Promise.all(directories)
resolve([resolvedUploads, ...resolvedDirectoryUploads].flat())
} catch (e) {
abort.abort(e)
reject(e)
}
})
}

/**
* 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<Upload> {
const destinationFile = `${root || this.root}/${destinationPath.replace(/^\//, '')}`
upload(destination: string, fileHandle: File|FileSystemFileEntry, root?: string): PCancelable<Upload> {
const destinationPath = `${root || this.root}/${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<Upload> => {
// 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<Upload> => {
// Register cancellation caller
onCancel(upload.cancel)

Expand Down
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down

0 comments on commit efb5024

Please sign in to comment.