From 434163f00afcfe333adfce4bb8938ff5de892c7f Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Mon, 22 Jul 2024 22:56:53 +0700 Subject: [PATCH] UBERF-7632 Upload folders to drive (#6104) Signed-off-by: Alexander Onnikov --- .../AttachmentStyleBoxCollabEditor.svelte | 4 +- .../src/components/CreateFolder.svelte | 29 ++---- .../src/components/DriveSpaceHeader.svelte | 8 +- .../src/components/FileDropArea.svelte | 32 +------ .../src/components/FilePanel.svelte | 2 +- plugins/drive-resources/src/index.ts | 10 +-- plugins/drive-resources/src/utils.ts | 79 ++++++++++++++-- plugins/drive/src/utils.ts | 21 ++++- plugins/uploader-resources/src/uppy.ts | 6 +- plugins/uploader-resources/src/utils.ts | 22 +++-- plugins/uploader/src/types.ts | 10 ++- plugins/uploader/src/utils.ts | 89 ++++++++++++++++++- 12 files changed, 229 insertions(+), 83 deletions(-) diff --git a/plugins/attachment-resources/src/components/AttachmentStyleBoxCollabEditor.svelte b/plugins/attachment-resources/src/components/AttachmentStyleBoxCollabEditor.svelte index 3e7b95f000..b66a691fed 100644 --- a/plugins/attachment-resources/src/components/AttachmentStyleBoxCollabEditor.svelte +++ b/plugins/attachment-resources/src/components/AttachmentStyleBoxCollabEditor.svelte @@ -142,7 +142,7 @@ list, { objectId: object._id, objectClass: object._class }, {}, - async (uuid, name, file, metadata) => { + async (uuid, name, file, path, metadata) => { await createAttachment(uuid, name, file, metadata) } ) @@ -158,7 +158,7 @@ files, { objectId: object._id, objectClass: object._class }, {}, - async (uuid, name, file, metadata) => { + async (uuid, name, file, path, metadata) => { await createAttachment(uuid, name, file, metadata) } ) diff --git a/plugins/drive-resources/src/components/CreateFolder.svelte b/plugins/drive-resources/src/components/CreateFolder.svelte index 7ca2bbceb9..afd672707d 100644 --- a/plugins/drive-resources/src/components/CreateFolder.svelte +++ b/plugins/drive-resources/src/components/CreateFolder.svelte @@ -15,13 +15,14 @@ // --> diff --git a/plugins/drive-resources/src/components/FilePanel.svelte b/plugins/drive-resources/src/components/FilePanel.svelte index b34d7a3478..7ae9e4cf53 100644 --- a/plugins/drive-resources/src/components/FilePanel.svelte +++ b/plugins/drive-resources/src/components/FilePanel.svelte @@ -74,7 +74,7 @@ maxNumberOfFiles: 1, hideProgress: true }, - async (uuid, name, file, metadata) => { + async (uuid, name, file, path, metadata) => { const data = { file: uuid, name, diff --git a/plugins/drive-resources/src/index.ts b/plugins/drive-resources/src/index.ts index d71d619a8c..7ba89894da 100644 --- a/plugins/drive-resources/src/index.ts +++ b/plugins/drive-resources/src/index.ts @@ -38,14 +38,14 @@ import MoveResource from './components/MoveResource.svelte' import ResourcePresenter from './components/ResourcePresenter.svelte' import { getDriveLink, getFileLink, getFolderLink, resolveLocation } from './navigation' -import { createFolder, renameResource, restoreFileVersion } from './utils' +import { showCreateFolderPopup, showRenameResourcePopup, restoreFileVersion } from './utils' async function CreateRootFolder (doc: Drive): Promise { - await createFolder(doc._id, drive.ids.Root) + await showCreateFolderPopup(doc._id, drive.ids.Root) } async function CreateChildFolder (doc: Folder): Promise { - await createFolder(doc.space, doc._id) + await showCreateFolderPopup(doc.space, doc._id) } async function EditDrive (drive: Drive): Promise { @@ -82,13 +82,13 @@ async function FileLinkProvider (doc: Doc): Promise { async function RenameFile (doc: File | File[]): Promise { if (!Array.isArray(doc)) { - await renameResource(doc) + await showRenameResourcePopup(doc) } } async function RenameFolder (doc: Folder | Folder[]): Promise { if (!Array.isArray(doc)) { - await renameResource(doc) + await showRenameResourcePopup(doc) } } diff --git a/plugins/drive-resources/src/utils.ts b/plugins/drive-resources/src/utils.ts index f5ded4b7dc..51121f5da0 100644 --- a/plugins/drive-resources/src/utils.ts +++ b/plugins/drive-resources/src/utils.ts @@ -14,11 +14,12 @@ // import { type Class, type Doc, type Ref, toIdMap } from '@hcengineering/core' -import type { Drive, FileVersion, Folder, Resource } from '@hcengineering/drive' -import drive from '@hcengineering/drive' -import { type Asset } from '@hcengineering/platform' +import { type Drive, type FileVersion, type Folder, type Resource, createFolder } from '@hcengineering/drive' +import drive, { createFile } from '@hcengineering/drive' +import { type Asset, setPlatformStatus, unknownError } from '@hcengineering/platform' import { getClient } from '@hcengineering/presentation' import { type AnySvelteComponent, showPopup } from '@hcengineering/ui' +import { uploadFiles } from '@hcengineering/uploader' import { openDoc } from '@hcengineering/view-resources' import CreateDrive from './components/CreateDrive.svelte' @@ -43,7 +44,11 @@ export function formatFileVersion (version: number): string { return `v${version}` } -export async function createFolder (space: Ref | undefined, parent: Ref, open = false): Promise { +export async function showCreateFolderPopup ( + space: Ref | undefined, + parent: Ref, + open = false +): Promise { showPopup(CreateFolder, { space, parent }, 'top', async (id) => { if (open && id !== undefined && id !== null) { await navigateToDoc(id, drive.class.Folder) @@ -51,7 +56,7 @@ export async function createFolder (space: Ref | undefined, parent: Ref { +export async function showCreateDrivePopup (open = false): Promise { showPopup(CreateDrive, {}, 'top', async (id) => { if (open && id !== undefined && id !== null) { await navigateToDoc(id, drive.class.Folder) @@ -59,11 +64,11 @@ export async function createDrive (open = false): Promise { }) } -export async function editDrive (drive: Drive): Promise { +export async function showEditDrivePopup (drive: Drive): Promise { showPopup(CreateDrive, { drive }) } -export async function renameResource (resource: Resource): Promise { +export async function showRenameResourcePopup (resource: Resource): Promise { showPopup(RenamePopup, { value: resource.name, format: 'text' }, undefined, async (res) => { if (res != null && res !== resource.name) { const client = getClient() @@ -148,3 +153,63 @@ export async function resolveParents (object: Resource): Promise { return parents.reverse() } + +export async function uploadFilesToDrive (files: DataTransfer, space: Ref, parent: Ref): Promise { + const client = getClient() + + const query = parent !== drive.ids.Root ? { space, path: parent } : { space } + const folders = await client.findAll(drive.class.Folder, query) + const foldersByName = new Map(folders.map((folder) => [folder.name, folder])) + + const findParent = async (path: string | undefined): Promise> => { + if (path == null || path.length === 0) { + return parent + } + + const segments = path.split('/').filter((p) => p.length > 0) + if (segments.length <= 1) { + return parent + } + + let current = parent + while (segments.length > 1) { + const name = segments.shift() + if (name !== undefined) { + let folder = foldersByName.get(name) + if (folder !== undefined) { + current = folder._id + } else { + current = await createFolder(client, space, { name, parent: current }) + folder = await client.findOne(drive.class.Folder, { _id: current }) + if (folder !== undefined) { + foldersByName.set(folder.name, folder) + } + } + } + } + return current + } + + const target = + parent !== drive.ids.Root + ? { objectId: parent, objectClass: drive.class.Folder } + : { objectId: space, objectClass: drive.class.Drive } + + await uploadFiles(files, target, {}, async (uuid, name, file, path, metadata) => { + const folder = await findParent(path) + try { + const data = { + file: uuid, + size: file.size, + type: file.type, + lastModified: file instanceof File ? file.lastModified : Date.now(), + name, + metadata + } + + await createFile(client, space, folder, data) + } catch (err) { + void setPlatformStatus(unknownError(err)) + } + }) +} diff --git a/plugins/drive/src/utils.ts b/plugins/drive/src/utils.ts index 170107c69c..7c7c698fd5 100644 --- a/plugins/drive/src/utils.ts +++ b/plugins/drive/src/utils.ts @@ -13,11 +13,30 @@ // limitations under the License. // -import { type AttachedData, type Ref, type TxOperations, generateId } from '@hcengineering/core' +import { type AttachedData, type Data, type Ref, type TxOperations, generateId } from '@hcengineering/core' import drive from './plugin' import type { Drive, File, FileVersion, Folder } from './types' +/** @public */ +export async function createFolder ( + client: TxOperations, + space: Ref, + data: Omit, 'path'> +): Promise> { + let path: Array> = [] + + if (data.parent !== drive.ids.Root) { + const parent = await client.findOne(drive.class.Folder, { _id: data.parent }) + if (parent === undefined) { + throw new Error('parent not found') + } + path = [parent._id, ...parent.path] + } + + return await client.createDoc(drive.class.Folder, space, { ...data, path }) +} + /** @public */ export async function createFile ( client: TxOperations, diff --git a/plugins/uploader-resources/src/uppy.ts b/plugins/uploader-resources/src/uppy.ts index d91f7500c4..89ba19475e 100644 --- a/plugins/uploader-resources/src/uppy.ts +++ b/plugins/uploader-resources/src/uppy.ts @@ -49,7 +49,9 @@ type Meta = IndexedObject type Body = IndexedObject /** @public */ -export type UppyMeta = Meta +export type UppyMeta = Meta & { + relativePath?: string +} /** @public */ export type UppyBody = Body & { @@ -89,7 +91,7 @@ export function getUppy (options: FileUploadOptions, onFileUploaded?: FileUpload const uuid = file?.response?.body?.uuid as Ref if (uuid !== undefined) { const metadata = await getFileMetadata(file.data, uuid) - await onFileUploaded(uuid, file.name, file.data, metadata) + await onFileUploaded(uuid, file.name, file.data, file.meta.relativePath, metadata) } } }) diff --git a/plugins/uploader-resources/src/utils.ts b/plugins/uploader-resources/src/utils.ts index 9d077fbf24..6b36e0f8da 100644 --- a/plugins/uploader-resources/src/utils.ts +++ b/plugins/uploader-resources/src/utils.ts @@ -14,7 +14,13 @@ // import { showPopup } from '@hcengineering/ui' -import type { FileUploadCallback, FileUploadOptions, FileUploadTarget } from '@hcengineering/uploader' +import { + type FileUploadCallback, + type FileUploadOptions, + type FileUploadTarget, + getDataTransferFiles, + toFileWithPath +} from '@hcengineering/uploader' import FileUploadPopup from './components/FileUploadPopup.svelte' @@ -38,19 +44,21 @@ export async function showFilesUploadPopup ( /** @public */ export async function uploadFiles ( - files: File[] | FileList, + files: File[] | FileList | DataTransfer, target: FileUploadTarget, options: FileUploadOptions, onFileUploaded: FileUploadCallback ): Promise { - if (files.length === 0) return + const items = + files instanceof DataTransfer ? await getDataTransferFiles(files) : Array.from(files, (p) => toFileWithPath(p)) + + if (items.length === 0) return const uppy = getUppy(options, onFileUploaded) - for (let index = 0; index < files.length; index++) { - const data = files[index] - const { name, type } = data - uppy.addFile({ name, type, data }) + for (const data of items) { + const { name, type, relativePath } = data + uppy.addFile({ name, type, data, meta: { relativePath } }) } if (options.hideProgress !== true) { diff --git a/plugins/uploader/src/types.ts b/plugins/uploader/src/types.ts index 42f8f3e5f5..5bc29fae22 100644 --- a/plugins/uploader/src/types.ts +++ b/plugins/uploader/src/types.ts @@ -15,6 +15,11 @@ import type { Blob as PlatformBlob, Class, Doc, Ref } from '@hcengineering/core' +/** @public */ +export interface FileWithPath extends File { + relativePath?: string +} + /** @public */ export type UploadFilesPopupFn = ( target: FileUploadTarget, @@ -24,7 +29,7 @@ export type UploadFilesPopupFn = ( /** @public */ export type UploadFilesFn = ( - files: File[] | FileList, + files: File[] | FileList | DataTransfer, target: FileUploadTarget, options: FileUploadOptions, onFileUploaded: FileUploadCallback @@ -48,6 +53,7 @@ export interface FileUploadOptions { export type FileUploadCallback = ( uuid: Ref, name: string, - file: File | Blob, + file: FileWithPath | Blob, + path: string | undefined, metadata: Record | undefined ) => Promise diff --git a/plugins/uploader/src/utils.ts b/plugins/uploader/src/utils.ts index eacd189f66..70af2a1189 100644 --- a/plugins/uploader/src/utils.ts +++ b/plugins/uploader/src/utils.ts @@ -16,7 +16,7 @@ import { getResource } from '@hcengineering/platform' import uploader from './plugin' -import type { FileUploadCallback, FileUploadOptions, FileUploadTarget } from './types' +import type { FileUploadCallback, FileUploadOptions, FileUploadTarget, FileWithPath } from './types' /** @public */ export async function showFilesUploadPopup ( @@ -30,7 +30,7 @@ export async function showFilesUploadPopup ( /** @public */ export async function uploadFiles ( - files: File[] | FileList, + files: File[] | FileList | DataTransfer, target: FileUploadTarget, options: FileUploadOptions, onFileUploaded: FileUploadCallback @@ -38,3 +38,88 @@ export async function uploadFiles ( const fn = await getResource(uploader.function.UploadFiles) await fn(files, target, options, onFileUploaded) } + +/** @public */ +export async function getDataTransferFiles (dataTransfer: DataTransfer): Promise { + try { + const accumulator = [] + const entries = Array.from(dataTransfer.items, getAsEntry) + for (const entry of entries) { + if (entry != null) { + const files = await fromEntry(entry) + if (Array.isArray(files)) { + accumulator.push(...files) + } else { + accumulator.push(files) + } + } + } + return accumulator + } catch { + return Array.from(dataTransfer.files, (file) => toFileWithPath(file)) + } +} + +/** @public */ +export function toFileWithPath (file: File, path?: string): FileWithPath { + const { webkitRelativePath } = file + Object.defineProperty(file, 'relativePath', { + value: + typeof path === 'string' + ? path + : typeof webkitRelativePath === 'string' && webkitRelativePath.length > 0 + ? webkitRelativePath + : file.name, + writable: false, + configurable: false, + enumerable: true + }) + return file +} + +function getAsEntry (item: DataTransferItem): FileSystemEntry | null | undefined { + // https://developer.mozilla.org/docs/Web/API/DataTransferItem/webkitGetAsEntry + return (item as any).getAsEntry === 'function' ? (item as any).getAsEntry() : item.webkitGetAsEntry() +} + +async function fromEntry (entry: FileSystemEntry): Promise { + return entry.isDirectory + ? await fromDirEntry(entry as FileSystemDirectoryEntry) + : await fromFileEntry(entry as FileSystemFileEntry) +} + +async function fromFileEntry (entry: FileSystemFileEntry): Promise { + return await new Promise((resolve, reject) => { + entry.file((file) => { + resolve(toFileWithPath(file, entry.fullPath)) + }, reject) + }) +} + +async function fromDirEntry (entry: FileSystemDirectoryEntry): Promise { + const reader = entry.createReader() + + return await new Promise((resolve, reject) => { + const promises: Promise[] = [] + + function readEntries (): void { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + reader.readEntries(async (entries: FileSystemEntry[]) => { + if (entries.length === 0) { + try { + const files = await Promise.all(promises) + resolve(files.flat()) + } catch (err) { + reject(err) + } + } else { + // https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries + promises.push(...entries.map(fromEntry)) + readEntries() + } + }, reject) + } + + readEntries() + }) +}