Files
documents/src/composables/useFileUpload.ts
T
Sebastian 6942469259 feat: pagination
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-05 20:14:40 -04:00

490 lines
14 KiB
TypeScript

/**
* 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<string> | ComputedRef<string>
serviceId: string | Ref<string> | ComputedRef<string>
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<Map<string, FileUploadProgress>> = ref(new Map())
const isUploading = ref(false)
// Get current collection ID (reactive)
const currentCollection: Ref<string | null> = ref(collectionId)
// Total upload progress
const totalProgress: ComputedRef<number> = 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<FileUploadProgress[]> = computed(() =>
Array.from(uploads.value.values()).filter(u => u.status === 'pending')
)
// Active uploads
const activeUploads: ComputedRef<FileUploadProgress[]> = computed(() =>
Array.from(uploads.value.values()).filter(u => u.status === 'uploading')
)
// Completed uploads
const completedUploads: ComputedRef<FileUploadProgress[]> = computed(() =>
Array.from(uploads.value.values()).filter(u => u.status === 'completed')
)
// Failed uploads
const failedUploads: ComputedRef<FileUploadProgress[]> = 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<void> => {
await new Promise<void>(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<FileUploadProgress[]> => {
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<string>()
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<Map<string, string>> => {
const folderPaths = extractFolderPaths()
const folderIdMap = new Map<string, string>() // 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 || ROOT_ID}`,
{ 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<string, string>
): Promise<EntityObject | null> => {
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(
`${entity.provider}:${entity.service}:${entity.collection || ROOT_ID}:${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<string> => {
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<EntityObject[]> => {
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<string, string> | 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<EntityObject | null> => {
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