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
66 changes: 55 additions & 11 deletions packages/web-app-files/src/HandleUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
file.meta = {
...file.meta,
tusEndpoint: endpoint,
uploadId: uuidV4()
uploadId: uuidV4(),
isFolder: file.type === 'directory'
}

filesToUpload[file.id] = file
Expand Down Expand Up @@ -171,6 +172,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
// file data
name: file.name,
mtime: (file.data as File).lastModified / 1000,
isFolder: file.type === 'directory',
// current path & space
spaceId: unref(this.space).id,
spaceName: unref(this.space).name,
Expand All @@ -190,6 +192,13 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
routeShareId: queryItemAsString(query?.shareId) || ''
}

if (file.type === 'directory') {
// folder files need their name appended to the relative folder
// so they can be created correctly. otherwise, only the parent directories
// would be created (because that's the behavior with files).
file.meta.relativeFolder = urlJoin(file.meta.relativeFolder, file.name)
}

filesToUpload[file.id] = file
}

Expand Down Expand Up @@ -278,7 +287,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
async createDirectoryTree(
filesToUpload: OcUppyFile[],
uploadFolder: Resource
): Promise<OcUppyFile[]> {
): Promise<{ filesToUpload: OcUppyFile[]; folderFiles: OcUppyFile[] }> {
const { webdav } = this.clientService
const space = unref(this.space)
const { id: currentFolderId, path: currentFolderPath } = uploadFolder
Expand All @@ -291,7 +300,16 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
const directoryTree: Record<string, any> = {}
const topLevelIds: Record<string, string> = {}

// folder files are manually constructed folders.
// they should not be part of the Uppy upload queue (which only knows files)
// and will be created separately. they need to be filtered out later.
const folderFiles: OcUppyFile[] = []

for (const file of filesToUpload.filter(({ meta }) => !!meta.relativeFolder)) {
if (file.type === 'directory') {
folderFiles.push(file)
}

const folders = file.meta.relativeFolder.split('/').filter(Boolean)
let current = directoryTree
// first folder is always top level
Expand All @@ -312,7 +330,6 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
const uppyFile = {
id: uuidV4(),
name: basename(path),
isFolder: true,
type: 'folder',
meta: {
spaceId: space.id,
Expand All @@ -325,7 +342,8 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
uploadId,
routeName,
routeDriveAliasAndItem,
routeShareId
routeShareId,
isFolder: true
}
}

Expand All @@ -339,7 +357,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
try {
const folder = await webdav.createFolder(space, {
path: urlJoin(currentFolderPath, path),
fetchFolder: isRoot
fetchFolder: isRoot // FIXME: remove once we get the fileId from the server here
})
this.uppyService.publish('uploadSuccess', {
...uppyFile,
Expand All @@ -365,17 +383,23 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
await createDirectoryLevel(directoryTree)

let filesToRemove: string[] = []
if (failedFolders.length) {
// remove files of folders that could not be created
if (failedFolders.length || folderFiles.length) {
// remove files of folders that could not be created and folder files
filesToRemove = filesToUpload
.filter((f) => failedFolders.some((r) => f.meta.relativeFolder.startsWith(r)))
.filter(
(f) => f.meta.isFolder || failedFolders.some((r) => f.meta.relativeFolder.startsWith(r))
)
.map(({ id }) => id)

for (const fileId of filesToRemove) {
this.uppy.removeFile(fileId)
}
}

return filesToUpload.filter(({ id }) => !filesToRemove.includes(id))
return {
filesToUpload: filesToUpload.filter(({ id }) => !filesToRemove.includes(id)),
folderFiles
}
}

/**
Expand All @@ -391,6 +415,17 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
const uploadFolder = this.getUploadFolder(uploadId)
let filesToUpload = this.prepareFiles(files, uploadFolder)

if (!this.directoryTreeCreateEnabled) {
// if directory tree creation is disabled, we need to remove all folder files
// from the upload queue
filesToUpload = filesToUpload.filter((file) => file.type !== 'directory')
if (!filesToUpload.length) {
// if there are no files left to upload, we can clear the inputs and do nothing
this.uppyService.clearInputs()
return
}
}

// quota check
if (this.quotaCheckEnabled) {
const quotaExceeded = this.checkQuotaExceeded(filesToUpload)
Expand Down Expand Up @@ -426,12 +461,21 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
}

this.uppyService.publish('uploadStarted')
let folderFiles: OcUppyFile[] = []
if (this.directoryTreeCreateEnabled) {
filesToUpload = await this.createDirectoryTree(filesToUpload, uploadFolder)
const result = await this.createDirectoryTree(filesToUpload, uploadFolder)
filesToUpload = result.filesToUpload
folderFiles = result.folderFiles
}

if (!filesToUpload.length) {
this.uppyService.publish('uploadCompleted', { successful: [] })
const successful: OcUppyFile[] = []
if (folderFiles.length) {
// case where only empty folders have been uploaded
successful.push(...folderFiles)
}
this.uppyService.publish('uploadCompleted', { successful })
this.uppyService.removeUploadFolder(uploadId)
return this.uppyService.clearInputs()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@
</template>

<script lang="ts">
import { computed, defineComponent, onMounted, onBeforeUnmount, ref } from 'vue'
import {
computed,
defineComponent,
onMounted,
onBeforeUnmount,
ref,
unref,
useTemplateRef
} from 'vue'
import { Resource } from '@opencloud-eu/web-client'
import { useService, ResourceIcon } from '@opencloud-eu/web-pkg'
import { useService, ResourceIcon, convertToMinimalUppyFile } from '@opencloud-eu/web-pkg'
import type { UppyService } from '@opencloud-eu/web-pkg'
import { getItemsViaDirectoryPicker } from '../../../helpers/directoryPicker'

export default defineComponent({
components: { ResourceIcon },
Expand All @@ -54,6 +63,8 @@ export default defineComponent({
},
setup(props) {
const uppyService = useService<UppyService>('$uppyService')
const input = useTemplateRef<HTMLInputElement>('input')

const isRemoteUploadInProgress = ref(uppyService.isRemoteUploadInProgress())

let uploadStartedSub: string
Expand All @@ -67,18 +78,44 @@ export default defineComponent({
(isRemoteUploadInProgress.value = uppyService.isRemoteUploadInProgress())
const onUploadCompleted = () => (isRemoteUploadInProgress.value = false)

const triggerUpload = async () => {
if (!props.isFolder || typeof (window as any).showDirectoryPicker !== 'function') {
// use native file picker for file uploads or if browser does not support the Directory API
unref(input).click()
return
}

try {
// use the Directory API so we can retrieve empty folders
const items = await getItemsViaDirectoryPicker((error) => uppyService.log(error))
const uppyFiles = convertToMinimalUppyFile('FolderUpload', items)
uppyService.addFiles(uppyFiles)
} catch (error) {
if (error.name !== 'AbortError') {
// AbortError means the user closed the picker. in any other case
// we assume something went wrong and we fall back to the native picker.
console.error('Error using DirectoryPicker, falling back to the native one:', error)
unref(input).click()
}
}
}

onMounted(() => {
uploadStartedSub = uppyService.subscribe('uploadStarted', onUploadStarted)
uploadCompletedSub = uppyService.subscribe('uploadCompleted', onUploadCompleted)
uppyService.registerUploadInput(unref(input))
})

onBeforeUnmount(() => {
uppyService.unsubscribe('uploadStarted', uploadStartedSub)
uppyService.unsubscribe('uploadCompleted', uploadCompletedSub)
uppyService.removeUploadInput(unref(input))
})
return {
isRemoteUploadInProgress,
resource
resource,
input,
triggerUpload
}
},
computed: {
Expand Down Expand Up @@ -113,17 +150,6 @@ export default defineComponent({
}
return { multiple: true }
}
},
mounted() {
this.$uppyService.registerUploadInput(this.$refs.input as HTMLInputElement)
},
beforeUnmount() {
this.$uppyService.removeUploadInput(this.$refs.input as HTMLInputElement)
},
methods: {
triggerUpload() {
;(this.$refs.input as HTMLInputElement).click()
}
}
})
</script>
Expand Down
54 changes: 54 additions & 0 deletions packages/web-app-files/src/helpers/directoryPicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { urlJoin } from '@opencloud-eu/web-client'
import { createFolderDummyFile } from '@opencloud-eu/web-pkg'

/**
* This method uses the Directory API to retrieve items from a directory
* that the user selects from their file system for upload.
* It returns an array of File objects, including dummy files for empty folders.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker
*/
export const getItemsViaDirectoryPicker = async (
onError?: (error?: unknown) => void
): Promise<File[]> => {
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker()

async function* getItemsFromDirectory(
dirHandle: FileSystemDirectoryHandle,
path: string
): AsyncGenerator<File> {
let hasFiles = false

for await (const [name, handle] of (dirHandle as any).entries()) {
if (handle.kind === 'directory') {
// recurse into the directory
yield* getItemsFromDirectory(handle, urlJoin(path, name))
} else {
hasFiles = true
try {
const file = await handle.getFile()
;(file as any).relativePath = urlJoin(path, name, { leadingSlash: true })
yield file
} catch (error) {
console.log(error)
onError?.(error)
}
}
}

if (!hasFiles) {
// empty folder, create a dummy file to represent it in the Uppy queue.
// note that a folder that only contains other folders is always considered empty,
// even if there are files located further down the hierarchy. this is because we don't
// have insight into the contents of the subfolders at this point.
yield createFolderDummyFile(path)
}
}

const items: File[] = []
for await (const item of getItemsFromDirectory(dirHandle, dirHandle.name)) {
items.push(item)
}

return items
}
Loading