/** * File upload composable * Handles file upload operations for file manager * Supports individual files and entire folder uploads with path preservation */ import { ref, computed, unref } from 'vue' import type { Ref, ComputedRef } from 'vue' import { useNodesStore, ROOT_ID } from '@DocumentsManager/stores/nodesStore' import { EntityObject } from '@DocumentsManager/models/entity' import type { DocumentInterface } from '@DocumentsManager/types/document' export interface FileUploadProgress { file: File progress: number status: 'pending' | 'uploading' | 'completed' | 'error' error?: string entity?: EntityObject /** Relative path within folder upload (e.g., "folder/subfolder/file.txt") */ relativePath?: string } export interface FileWithPath { file: File relativePath: string } interface NormalizedUploadItem { file: File relativePath?: string } export interface AddUploadsWithPathsOptions { batchSize?: number onProgress?: (processed: number, total: number) => void } export interface UseFileUploadOptions { providerId: string | Ref | ComputedRef serviceId: string | Ref | ComputedRef collectionId?: string | null maxFileSize?: number allowedTypes?: string[] } export function useFileUpload(options: UseFileUploadOptions) { const nodesStore = useNodesStore() const { providerId, serviceId, collectionId = ROOT_ID, maxFileSize, allowedTypes } = options const currentProviderId = () => unref(providerId) const currentServiceId = () => unref(serviceId) const uploads: Ref> = ref(new Map()) const isUploading = ref(false) // Get current collection ID (reactive) const currentCollection: Ref = ref(collectionId) // Total upload progress const totalProgress: ComputedRef = computed(() => { const items = Array.from(uploads.value.values()) if (items.length === 0) return 0 const total = items.reduce((sum, item) => sum + item.progress, 0) return Math.round(total / items.length) }) // Pending uploads const pendingUploads: ComputedRef = computed(() => Array.from(uploads.value.values()).filter(u => u.status === 'pending') ) // Active uploads const activeUploads: ComputedRef = computed(() => Array.from(uploads.value.values()).filter(u => u.status === 'uploading') ) // Completed uploads const completedUploads: ComputedRef = computed(() => Array.from(uploads.value.values()).filter(u => u.status === 'completed') ) // Failed uploads const failedUploads: ComputedRef = computed(() => Array.from(uploads.value.values()).filter(u => u.status === 'error') ) // Validate a file const validateFile = (file: File): string | null => { if (maxFileSize && file.size > maxFileSize) { return `File size exceeds maximum allowed (${formatSize(maxFileSize)})` } if (allowedTypes && allowedTypes.length > 0) { const isAllowed = allowedTypes.some(type => { if (type.endsWith('/*')) { // Wildcard match (e.g., "image/*") return file.type.startsWith(type.slice(0, -1)) } return file.type === type }) if (!isAllowed) { return `File type ${file.type} is not allowed` } } return null } // Format file size const formatSize = (bytes: number): string => { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } // Generate unique upload ID const generateUploadId = (file: File, relativePath?: string): string => { const pathPart = relativePath || file.name return `${pathPart}-${file.size}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` } const createUploadProgress = (file: File, relativePath?: string): FileUploadProgress => { const error = validateFile(file) return { file, progress: 0, status: error ? 'error' : 'pending', error: error || undefined, relativePath, } } const yieldToBrowser = async (): Promise => { await new Promise(resolve => { if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(() => resolve()) return } setTimeout(resolve, 0) }) } const normalizeUploadsWithPaths = ( filesOrList: FileList | File[] | FileWithPath[] ): NormalizedUploadItem[] => { if (filesOrList instanceof FileList) { return Array.from(filesOrList, file => ({ file, relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name, })) } if (filesOrList.length === 0) { return [] } if (filesOrList[0] instanceof File) { return (filesOrList as File[]).map(file => ({ file, relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name, })) } return (filesOrList as FileWithPath[]).map(({ file, relativePath }) => ({ file, relativePath })) } // Add files to upload queue const addFiles = (files: FileList | File[]): FileUploadProgress[] => { const added: FileUploadProgress[] = [] const nextUploads = new Map(uploads.value) for (const file of files) { const progress = createUploadProgress(file) const uploadId = generateUploadId(file) nextUploads.set(uploadId, progress) added.push(progress) } uploads.value = nextUploads return added } // Add files with relative paths (for folder uploads) const addFilesWithPaths = ( filesOrList: FileList | File[] | FileWithPath[] ): FileUploadProgress[] => { const added: FileUploadProgress[] = [] const nextUploads = new Map(uploads.value) for (const { file, relativePath } of normalizeUploadsWithPaths(filesOrList)) { const progress = createUploadProgress(file, relativePath) const uploadId = generateUploadId(file, relativePath) nextUploads.set(uploadId, progress) added.push(progress) } uploads.value = nextUploads return added } const addFilesWithPathsBatched = async ( filesOrList: FileList | File[] | FileWithPath[], options: AddUploadsWithPathsOptions = {} ): Promise => { const items = normalizeUploadsWithPaths(filesOrList) const total = items.length const batchSize = Math.max(1, options.batchSize ?? 250) const added: FileUploadProgress[] = [] options.onProgress?.(0, total) for (let start = 0; start < total; start += batchSize) { const batch = items.slice(start, start + batchSize) const nextUploads = new Map(uploads.value) for (const { file, relativePath } of batch) { const progress = createUploadProgress(file, relativePath) const uploadId = generateUploadId(file, relativePath) nextUploads.set(uploadId, progress) added.push(progress) } uploads.value = nextUploads const processed = Math.min(start + batch.length, total) options.onProgress?.(processed, total) if (processed < total) { await yieldToBrowser() } } return added } // Extract unique folder paths from pending uploads const extractFolderPaths = (): string[] => { const folders = new Set() for (const upload of uploads.value.values()) { if (upload.relativePath && upload.status === 'pending') { // Get all parent directories const parts = upload.relativePath.split('/') parts.pop() // Remove filename let currentPath = '' for (const part of parts) { currentPath = currentPath ? `${currentPath}/${part}` : part folders.add(currentPath) } } } // Sort by depth (shortest paths first) to ensure parent folders are created first return Array.from(folders).sort((a, b) => { const depthA = a.split('/').length const depthB = b.split('/').length return depthA - depthB }) } // Create folder structure for uploads const createFolderStructure = async (): Promise> => { const folderPaths = extractFolderPaths() const folderIdMap = new Map() // path -> collection ID for (const folderPath of folderPaths) { const parts = folderPath.split('/') const folderName = parts[parts.length - 1] const parentPath = parts.slice(0, -1).join('/') // Determine parent collection ID const parentId = parentPath ? (folderIdMap.get(parentPath) ?? currentCollection.value) : currentCollection.value try { // Create the folder const collection = await nodesStore.createCollection( currentProviderId(), currentServiceId(), parentId, { label: folderName, owner: '' } ) folderIdMap.set(folderPath, String(collection.identifier)) } catch (e) { console.error(`Failed to create folder: ${folderPath}`, e) // Try to continue with other folders } } return folderIdMap } // Upload a single file const uploadFile = async ( uploadId: string, folderIdMap?: Map ): Promise => { const upload = uploads.value.get(uploadId) if (!upload || upload.status !== 'pending') { return null } upload.status = 'uploading' upload.progress = 0 try { // Determine target collection based on relative path let targetCollection = currentCollection.value if (upload.relativePath && folderIdMap) { const parts = upload.relativePath.split('/') parts.pop() // Remove filename const parentPath = parts.join('/') if (parentPath && folderIdMap.has(parentPath)) { targetCollection = folderIdMap.get(parentPath)! } } // Convert file to base64 const content = await fileToBase64(upload.file) upload.progress = 50 // Create the entity const entity = await nodesStore.createEntity( currentProviderId(), currentServiceId(), targetCollection || ROOT_ID, { '@type': 'documents.properties', urid: null, format: null, encoding: null, label: upload.file.name, mime: upload.file.type || 'application/octet-stream', size: upload.file.size, } as DocumentInterface ) upload.progress = 75 // Write the content await nodesStore.writeEntity( currentProviderId(), currentServiceId(), targetCollection || ROOT_ID, String(entity.identifier), content ) upload.progress = 100 upload.status = 'completed' upload.entity = entity return entity } catch (e) { upload.status = 'error' upload.error = e instanceof Error ? e.message : 'Upload failed' return null } } // Convert file to base64 const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { const result = reader.result as string // Remove data URL prefix (e.g., "data:image/png;base64,") const base64 = result.split(',')[1] || result resolve(base64) } reader.onerror = reject reader.readAsDataURL(file) }) } // Upload all pending files const uploadAll = async (): Promise => { isUploading.value = true const entities: EntityObject[] = [] try { // Check if any uploads have relative paths (folder upload) const hasFolderUploads = Array.from(uploads.value.values()).some( u => u.relativePath && u.relativePath.includes('/') && u.status === 'pending' ) // Create folder structure first if needed let folderIdMap: Map | undefined if (hasFolderUploads) { folderIdMap = await createFolderStructure() } // Upload all files for (const [uploadId, upload] of uploads.value) { if (upload.status === 'pending') { const entity = await uploadFile(uploadId, folderIdMap) if (entity) { entities.push(entity) } } } } finally { isUploading.value = false } return entities } // Remove an upload from the queue const removeUpload = (uploadId: string) => { uploads.value.delete(uploadId) } // Clear completed uploads const clearCompleted = () => { for (const [id, upload] of uploads.value) { if (upload.status === 'completed') { uploads.value.delete(id) } } } // Clear all uploads const clearAll = () => { uploads.value.clear() } // Retry a failed upload const retryUpload = async (uploadId: string): Promise => { const upload = uploads.value.get(uploadId) if (!upload || upload.status !== 'error') { return null } upload.status = 'pending' upload.error = undefined upload.progress = 0 return await uploadFile(uploadId) } // Set current collection const setCollection = (collectionId: string | null) => { currentCollection.value = collectionId } return { // State uploads, isUploading, currentCollection, // Computed totalProgress, pendingUploads, activeUploads, completedUploads, failedUploads, // Methods validateFile, addFiles, addFilesWithPaths, addFilesWithPathsBatched, uploadFile, uploadAll, removeUpload, clearCompleted, clearAll, retryUpload, setCollection, } } export default useFileUpload