Initial commit
This commit is contained in:
11
src/composables/index.ts
Normal file
11
src/composables/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Central export point for all Files module composables
|
||||
*/
|
||||
|
||||
export { useFileManager } from './useFileManager'
|
||||
export { useFileSelection } from './useFileSelection'
|
||||
export { useFileUpload } from './useFileUpload'
|
||||
|
||||
export type { UseFileManagerOptions } from './useFileManager'
|
||||
export type { UseFileSelectionOptions } from './useFileSelection'
|
||||
export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload'
|
||||
303
src/composables/useFileManager.ts
Normal file
303
src/composables/useFileManager.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* File Manager composable for convenient file/folder operations
|
||||
* Provides reactive access to file manager state and actions
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { useProvidersStore } from '@FileManager/stores/providersStore'
|
||||
import { useServicesStore } from '@FileManager/stores/servicesStore'
|
||||
import { useNodesStore, ROOT_ID } from '@FileManager/stores/nodesStore'
|
||||
import type { FilterCondition, SortCondition, RangeCondition } from '@FileManager/types/common'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
|
||||
// Base URL for file manager transfer endpoints
|
||||
const TRANSFER_BASE_URL = '/m/file_manager'
|
||||
|
||||
export interface UseFileManagerOptions {
|
||||
providerId: string
|
||||
serviceId: string
|
||||
autoFetch?: boolean
|
||||
}
|
||||
|
||||
export function useFileManager(options: UseFileManagerOptions) {
|
||||
const providersStore = useProvidersStore()
|
||||
const servicesStore = useServicesStore()
|
||||
const nodesStore = useNodesStore()
|
||||
|
||||
const { providerId, serviceId, autoFetch = false } = options
|
||||
|
||||
// Current location (folder being viewed)
|
||||
const currentLocation: Ref<string> = ref(ROOT_ID)
|
||||
|
||||
// Loading/error state
|
||||
const isLoading = computed(() => nodesStore.loading)
|
||||
const error = computed(() => nodesStore.error)
|
||||
|
||||
// Provider and service
|
||||
const provider = computed(() => providersStore.getProvider(providerId))
|
||||
const service = computed(() => servicesStore.getService(providerId, serviceId))
|
||||
const rootId = computed(() => servicesStore.getRootId(providerId, serviceId) || ROOT_ID)
|
||||
|
||||
// Current children
|
||||
const currentChildren = computed(() =>
|
||||
nodesStore.getChildren(providerId, serviceId, currentLocation.value)
|
||||
)
|
||||
|
||||
const currentCollections: ComputedRef<FileCollectionObject[]> = computed(() =>
|
||||
nodesStore.getChildCollections(providerId, serviceId, currentLocation.value)
|
||||
)
|
||||
|
||||
const currentEntities: ComputedRef<FileEntityObject[]> = computed(() =>
|
||||
nodesStore.getChildEntities(providerId, serviceId, currentLocation.value)
|
||||
)
|
||||
|
||||
// Breadcrumb path
|
||||
const breadcrumbs = computed(() => {
|
||||
if (currentLocation.value === ROOT_ID) {
|
||||
return []
|
||||
}
|
||||
return nodesStore.getPath(providerId, serviceId, currentLocation.value)
|
||||
})
|
||||
|
||||
// Is at root?
|
||||
const isAtRoot = computed(() => currentLocation.value === ROOT_ID)
|
||||
|
||||
// Navigate to a folder
|
||||
const navigateTo = async (collectionId: string | null) => {
|
||||
currentLocation.value = collectionId || ROOT_ID
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// Navigate up one level
|
||||
const navigateUp = async () => {
|
||||
if (currentLocation.value === ROOT_ID) {
|
||||
return
|
||||
}
|
||||
const currentNode = nodesStore.getNode(providerId, serviceId, currentLocation.value)
|
||||
if (currentNode) {
|
||||
await navigateTo(currentNode.in || ROOT_ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to root
|
||||
const navigateToRoot = async () => {
|
||||
await navigateTo(ROOT_ID)
|
||||
}
|
||||
|
||||
// Refresh current location
|
||||
const refresh = async (
|
||||
filter?: FilterCondition[] | null,
|
||||
sort?: SortCondition[] | null,
|
||||
range?: RangeCondition | null
|
||||
) => {
|
||||
await nodesStore.fetchNodes(
|
||||
providerId,
|
||||
serviceId,
|
||||
currentLocation.value === ROOT_ID ? null : currentLocation.value,
|
||||
false,
|
||||
filter,
|
||||
sort,
|
||||
range
|
||||
)
|
||||
}
|
||||
|
||||
// Create a new folder
|
||||
const createFolder = async (label: string): Promise<FileCollectionObject> => {
|
||||
return await nodesStore.createCollection(
|
||||
providerId,
|
||||
serviceId,
|
||||
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
||||
{ label }
|
||||
)
|
||||
}
|
||||
|
||||
// Create a new file
|
||||
const createFile = async (
|
||||
label: string,
|
||||
mime: string = 'application/octet-stream'
|
||||
): Promise<FileEntityObject> => {
|
||||
return await nodesStore.createEntity(
|
||||
providerId,
|
||||
serviceId,
|
||||
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
||||
{ label, mime }
|
||||
)
|
||||
}
|
||||
|
||||
// Rename a node
|
||||
const renameNode = async (nodeId: string, newLabel: string) => {
|
||||
const node = nodesStore.getNode(providerId, serviceId, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
if (node['@type'] === 'files.collection') {
|
||||
return await nodesStore.modifyCollection(providerId, serviceId, nodeId, { label: newLabel })
|
||||
} else {
|
||||
return await nodesStore.modifyEntity(providerId, serviceId, node.in, nodeId, { label: newLabel })
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a node
|
||||
const deleteNode = async (nodeId: string): Promise<boolean> => {
|
||||
const node = nodesStore.getNode(providerId, serviceId, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
if (node['@type'] === 'files.collection') {
|
||||
return await nodesStore.destroyCollection(providerId, serviceId, nodeId)
|
||||
} else {
|
||||
return await nodesStore.destroyEntity(providerId, serviceId, node.in, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy a node
|
||||
const copyNode = async (nodeId: string, destinationId?: string | null) => {
|
||||
const node = nodesStore.getNode(providerId, serviceId, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
const destination = destinationId ?? currentLocation.value
|
||||
|
||||
if (node['@type'] === 'files.collection') {
|
||||
return await nodesStore.copyCollection(providerId, serviceId, nodeId, destination)
|
||||
} else {
|
||||
return await nodesStore.copyEntity(providerId, serviceId, node.in, nodeId, destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Move a node
|
||||
const moveNode = async (nodeId: string, destinationId?: string | null) => {
|
||||
const node = nodesStore.getNode(providerId, serviceId, nodeId)
|
||||
if (!node) {
|
||||
throw new Error('Node not found')
|
||||
}
|
||||
|
||||
const destination = destinationId ?? currentLocation.value
|
||||
|
||||
if (node['@type'] === 'files.collection') {
|
||||
return await nodesStore.moveCollection(providerId, serviceId, nodeId, destination)
|
||||
} else {
|
||||
return await nodesStore.moveEntity(providerId, serviceId, node.in, nodeId, destination)
|
||||
}
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const readFile = async (entityId: string): Promise<string | null> => {
|
||||
const node = nodesStore.getNode(providerId, serviceId, entityId)
|
||||
if (!node || node['@type'] !== 'files.entity') {
|
||||
throw new Error('Entity not found')
|
||||
}
|
||||
return await nodesStore.readEntity(providerId, serviceId, node.in || ROOT_ID, entityId)
|
||||
}
|
||||
|
||||
// Write file content
|
||||
const writeFile = async (entityId: string, content: string): Promise<number> => {
|
||||
const node = nodesStore.getNode(providerId, serviceId, entityId)
|
||||
if (!node || node['@type'] !== 'files.entity') {
|
||||
throw new Error('Entity not found')
|
||||
}
|
||||
return await nodesStore.writeEntity(providerId, serviceId, node.in, entityId, content)
|
||||
}
|
||||
|
||||
// Download a single file
|
||||
const downloadEntity = (entityId: string, collectionId?: string | null): void => {
|
||||
const collection = collectionId ?? currentLocation.value
|
||||
// Use path parameters: /download/entity/{provider}/{service}/{collection}/{identifier}
|
||||
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
|
||||
|
||||
// Trigger download by opening URL (browser handles it)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// Download a collection (folder) as ZIP
|
||||
const downloadCollection = (collectionId: string): void => {
|
||||
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
|
||||
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collectionId)}`
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// Download multiple items as ZIP archive
|
||||
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
|
||||
const collection = collectionId ?? currentLocation.value
|
||||
const params = new URLSearchParams({
|
||||
provider: providerId,
|
||||
service: serviceId,
|
||||
})
|
||||
ids.forEach(id => params.append('ids[]', id))
|
||||
if (name) {
|
||||
params.append('name', name)
|
||||
}
|
||||
if (collection && collection !== ROOT_ID) {
|
||||
params.append('collection', collection)
|
||||
}
|
||||
|
||||
const url = `${TRANSFER_BASE_URL}/download/archive?${params.toString()}`
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// Initialize - fetch providers, services, and initial nodes if autoFetch
|
||||
const initialize = async () => {
|
||||
if (!providersStore.initialized) {
|
||||
await providersStore.fetchProviders()
|
||||
}
|
||||
if (!servicesStore.initialized) {
|
||||
await servicesStore.fetchServices()
|
||||
}
|
||||
if (autoFetch) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentLocation,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Provider/Service
|
||||
provider,
|
||||
service,
|
||||
rootId,
|
||||
|
||||
// Current view
|
||||
currentChildren,
|
||||
currentCollections,
|
||||
currentEntities,
|
||||
breadcrumbs,
|
||||
isAtRoot,
|
||||
|
||||
// Navigation
|
||||
navigateTo,
|
||||
navigateUp,
|
||||
navigateToRoot,
|
||||
refresh,
|
||||
|
||||
// Operations
|
||||
createFolder,
|
||||
createFile,
|
||||
renameNode,
|
||||
deleteNode,
|
||||
copyNode,
|
||||
moveNode,
|
||||
readFile,
|
||||
writeFile,
|
||||
downloadEntity,
|
||||
downloadCollection,
|
||||
downloadArchive,
|
||||
|
||||
// Initialize
|
||||
initialize,
|
||||
|
||||
// Constants
|
||||
ROOT_ID,
|
||||
}
|
||||
}
|
||||
|
||||
export default useFileManager
|
||||
173
src/composables/useFileSelection.ts
Normal file
173
src/composables/useFileSelection.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* File selection composable
|
||||
* Provides reactive selection management for file manager
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
|
||||
type NodeRecord = FileCollectionObject | FileEntityObject
|
||||
|
||||
export interface UseFileSelectionOptions {
|
||||
multiple?: boolean
|
||||
allowFolders?: boolean
|
||||
allowFiles?: boolean
|
||||
}
|
||||
|
||||
export function useFileSelection(options: UseFileSelectionOptions = {}) {
|
||||
const {
|
||||
multiple = true,
|
||||
allowFolders = true,
|
||||
allowFiles = true
|
||||
} = options
|
||||
|
||||
const selectedIds: Ref<Set<string>> = ref(new Set())
|
||||
const selectedNodes: Ref<Map<string, NodeRecord>> = ref(new Map())
|
||||
|
||||
// Get selected count
|
||||
const count: ComputedRef<number> = computed(() => selectedIds.value.size)
|
||||
|
||||
// Check if any selected
|
||||
const hasSelection: ComputedRef<boolean> = computed(() => selectedIds.value.size > 0)
|
||||
|
||||
// Get selected IDs as array
|
||||
const selectedIdArray: ComputedRef<string[]> = computed(() =>
|
||||
Array.from(selectedIds.value)
|
||||
)
|
||||
|
||||
// Get selected nodes as array
|
||||
const selectedNodeArray: ComputedRef<NodeRecord[]> = computed(() =>
|
||||
Array.from(selectedNodes.value.values())
|
||||
)
|
||||
|
||||
// Get selected collections only
|
||||
const selectedCollections: ComputedRef<FileCollectionObject[]> = computed(() =>
|
||||
selectedNodeArray.value.filter(
|
||||
(node): node is FileCollectionObject => node['@type'] === 'files.collection'
|
||||
)
|
||||
)
|
||||
|
||||
// Get selected entities only
|
||||
const selectedEntities: ComputedRef<FileEntityObject[]> = computed(() =>
|
||||
selectedNodeArray.value.filter(
|
||||
(node): node is FileEntityObject => node['@type'] === 'files.entity'
|
||||
)
|
||||
)
|
||||
|
||||
// Check if a node is selected
|
||||
const isSelected = (nodeId: string): boolean => {
|
||||
return selectedIds.value.has(nodeId)
|
||||
}
|
||||
|
||||
// Check if node type is allowed
|
||||
const isTypeAllowed = (node: NodeRecord): boolean => {
|
||||
if (node['@type'] === 'files.collection' && !allowFolders) {
|
||||
return false
|
||||
}
|
||||
if (node['@type'] === 'files.entity' && !allowFiles) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Select a node
|
||||
const select = (node: NodeRecord) => {
|
||||
if (!isTypeAllowed(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
// Clear previous selection for single select
|
||||
selectedIds.value.clear()
|
||||
selectedNodes.value.clear()
|
||||
}
|
||||
|
||||
selectedIds.value.add(node.id)
|
||||
selectedNodes.value.set(node.id, node)
|
||||
}
|
||||
|
||||
// Deselect a node
|
||||
const deselect = (nodeId: string) => {
|
||||
selectedIds.value.delete(nodeId)
|
||||
selectedNodes.value.delete(nodeId)
|
||||
}
|
||||
|
||||
// Toggle selection
|
||||
const toggle = (node: NodeRecord) => {
|
||||
if (isSelected(node.id)) {
|
||||
deselect(node.id)
|
||||
} else {
|
||||
select(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Select multiple nodes
|
||||
const selectMultiple = (nodes: NodeRecord[]) => {
|
||||
if (!multiple) {
|
||||
// For single select, only select the last one
|
||||
const lastNode = nodes[nodes.length - 1]
|
||||
if (lastNode && isTypeAllowed(lastNode)) {
|
||||
selectedIds.value.clear()
|
||||
selectedNodes.value.clear()
|
||||
selectedIds.value.add(lastNode.id)
|
||||
selectedNodes.value.set(lastNode.id, lastNode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (isTypeAllowed(node)) {
|
||||
selectedIds.value.add(node.id)
|
||||
selectedNodes.value.set(node.id, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select all from a list
|
||||
const selectAll = (nodes: NodeRecord[]) => {
|
||||
if (!multiple) {
|
||||
return
|
||||
}
|
||||
selectMultiple(nodes)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
const clear = () => {
|
||||
selectedIds.value.clear()
|
||||
selectedNodes.value.clear()
|
||||
}
|
||||
|
||||
// Set selection (replace current)
|
||||
const setSelection = (nodes: NodeRecord[]) => {
|
||||
clear()
|
||||
selectMultiple(nodes)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedIds,
|
||||
selectedNodes,
|
||||
|
||||
// Computed
|
||||
count,
|
||||
hasSelection,
|
||||
selectedIdArray,
|
||||
selectedNodeArray,
|
||||
selectedCollections,
|
||||
selectedEntities,
|
||||
|
||||
// Methods
|
||||
isSelected,
|
||||
select,
|
||||
deselect,
|
||||
toggle,
|
||||
selectMultiple,
|
||||
selectAll,
|
||||
clear,
|
||||
setSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export default useFileSelection
|
||||
425
src/composables/useFileUpload.ts
Normal file
425
src/composables/useFileUpload.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* File upload composable
|
||||
* Handles file upload operations for file manager
|
||||
* Supports individual files and entire folder uploads with path preservation
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { useNodesStore, ROOT_ID } from '@FileManager/stores/nodesStore'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
|
||||
export interface FileUploadProgress {
|
||||
file: File
|
||||
progress: number
|
||||
status: 'pending' | 'uploading' | 'completed' | 'error'
|
||||
error?: string
|
||||
entity?: FileEntityObject
|
||||
/** Relative path within folder upload (e.g., "folder/subfolder/file.txt") */
|
||||
relativePath?: string
|
||||
}
|
||||
|
||||
export interface FileWithPath {
|
||||
file: File
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export interface UseFileUploadOptions {
|
||||
providerId: string
|
||||
serviceId: 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 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)}`
|
||||
}
|
||||
|
||||
// Add files to upload queue
|
||||
const addFiles = (files: FileList | File[]): FileUploadProgress[] => {
|
||||
const added: FileUploadProgress[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const error = validateFile(file)
|
||||
const uploadId = generateUploadId(file)
|
||||
|
||||
const progress: FileUploadProgress = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: error ? 'error' : 'pending',
|
||||
error: error || undefined
|
||||
}
|
||||
|
||||
uploads.value.set(uploadId, progress)
|
||||
added.push(progress)
|
||||
}
|
||||
|
||||
return added
|
||||
}
|
||||
|
||||
// Add files with relative paths (for folder uploads)
|
||||
const addFilesWithPaths = (
|
||||
filesOrList: FileList | File[] | FileWithPath[]
|
||||
): FileUploadProgress[] => {
|
||||
const added: FileUploadProgress[] = []
|
||||
|
||||
// Handle FileList from webkitdirectory input
|
||||
if (filesOrList instanceof FileList || (Array.isArray(filesOrList) && filesOrList[0] instanceof File && !('relativePath' in filesOrList[0]))) {
|
||||
const fileList = filesOrList as FileList | File[]
|
||||
for (const file of fileList) {
|
||||
// webkitRelativePath is set on files from folder input
|
||||
const relativePath = (file as any).webkitRelativePath || file.name
|
||||
const error = validateFile(file)
|
||||
const uploadId = generateUploadId(file, relativePath)
|
||||
|
||||
const progress: FileUploadProgress = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: error ? 'error' : 'pending',
|
||||
error: error || undefined,
|
||||
relativePath
|
||||
}
|
||||
|
||||
uploads.value.set(uploadId, progress)
|
||||
added.push(progress)
|
||||
}
|
||||
} else {
|
||||
// Handle FileWithPath array (from drag & drop folder processing)
|
||||
const filesWithPaths = filesOrList as FileWithPath[]
|
||||
for (const { file, relativePath } of filesWithPaths) {
|
||||
const error = validateFile(file)
|
||||
const uploadId = generateUploadId(file, relativePath)
|
||||
|
||||
const progress: FileUploadProgress = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: error ? 'error' : 'pending',
|
||||
error: error || undefined,
|
||||
relativePath
|
||||
}
|
||||
|
||||
uploads.value.set(uploadId, progress)
|
||||
added.push(progress)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
providerId,
|
||||
serviceId,
|
||||
parentId,
|
||||
{ label: folderName }
|
||||
)
|
||||
folderIdMap.set(folderPath, collection.id)
|
||||
} 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<FileEntityObject | 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(
|
||||
providerId,
|
||||
serviceId,
|
||||
targetCollection,
|
||||
{
|
||||
label: upload.file.name,
|
||||
mime: upload.file.type || 'application/octet-stream',
|
||||
size: upload.file.size,
|
||||
}
|
||||
)
|
||||
|
||||
upload.progress = 75
|
||||
|
||||
// Write the content
|
||||
await nodesStore.writeEntity(
|
||||
providerId,
|
||||
serviceId,
|
||||
targetCollection,
|
||||
entity.id,
|
||||
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<FileEntityObject[]> => {
|
||||
isUploading.value = true
|
||||
const entities: FileEntityObject[] = []
|
||||
|
||||
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<FileEntityObject | 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,
|
||||
uploadFile,
|
||||
uploadAll,
|
||||
removeUpload,
|
||||
clearCompleted,
|
||||
clearAll,
|
||||
retryUpload,
|
||||
setCollection,
|
||||
}
|
||||
}
|
||||
|
||||
export default useFileUpload
|
||||
Reference in New Issue
Block a user