import { useCallback, useRef, useState } from 'react'; import useFileDrop from './useFileDrop'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; const mapFilesToEntries = (filesInput) => { if (!filesInput) { return []; } const files = Array.isArray(filesInput) ? filesInput : Array.from(filesInput); return files .filter(Boolean) .map((file) => { const relativePath = file?.webkitRelativePath ?? ''; const segments = relativePath ? relativePath .split('/') .slice(0, -1) .filter(Boolean) : []; return { file, segments }; }); }; const useDocumentUploads = ({ apiClient, token, selectedFolder, currentFolderName, ensureFolderData, refreshCurrentFolder, setLoading, shellRef, }) => { const [dropOverlayState, setDropOverlayState] = useState({ active: false, folderName: DEFAULT_FOLDER_NAME, }); const dragCounterRef = useRef(0); const folderPathCacheRef = useRef(new Map()); const queueIdRef = useRef(0); const [uploadQueue, setUploadQueue] = useState([]); const uploadFile = useCallback( async (file, targetFolderId) => { if (!file || file.size === 0) { return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null }; } const formData = new FormData(); formData.append('file', file, file.name); if (targetFolderId && targetFolderId !== 'root') { formData.append('folder_id', targetFolderId); } try { const { data, status } = await apiClient.post('/documents', formData); const duplicate = data?.reused || status === 200; const document = data?.document ?? data ?? null; return { document, duplicate, statusCode: status ?? (duplicate ? 200 : 201), conflictDocumentId: null, }; } catch (error) { if (error.response?.status === 409) { const conflictId = error.response?.data?.details?.conflict_document_id ?? null; let conflictDocument = null; if (conflictId) { try { const { data } = await apiClient.get(`/documents/${conflictId}`); conflictDocument = data?.document ?? data ?? null; } catch (fetchError) { console.warn('[Uploads] failed to fetch conflict document', fetchError); } } return { document: conflictDocument, duplicate: true, statusCode: 409, conflictDocumentId: conflictId, }; } const message = error.response?.data?.error || `Failed to upload ${file.name}.`; const wrapped = Object.assign(new Error(message), { response: error.response }); throw wrapped; } }, [apiClient], ); const appendQueueItems = useCallback((entries, targetFolderId) => { const baseId = Date.now(); const items = entries.map(({ file }) => { queueIdRef.current += 1; return { id: `upload-${baseId}-${queueIdRef.current}`, name: file?.name || 'Unnamed file', size: file?.size ?? null, folderId: targetFolderId ?? selectedFolder ?? 'root', status: 'pending', error: null, code: null, document: null, conflictDocumentId: null, }; }); if (items.length) { setUploadQueue((current) => [...current, ...items]); } return items; }, [selectedFolder]); const updateQueueItem = useCallback((id, patch) => { if (!id) { return; } setUploadQueue((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)), ); }, []); const ensureFolderPathOnServer = useCallback( async (baseFolderId, segments) => { const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); if (trimmedSegments.length === 0) { return baseFolderId ?? null; } const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`; const cache = folderPathCacheRef.current; if (cache.has(cacheKey)) { return cache.get(cacheKey); } const payload = { parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null, segments: trimmedSegments, }; const { data } = await apiClient.post('/folders/path', payload); cache.set(cacheKey, data.folder.id); return data.folder.id; }, [apiClient], ); const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { if (!dataTransfer) { throw new Error('No drop payload found.'); } const items = Array.from(dataTransfer.items || []); console.info('[Uploads] drop start', { items: items.length, files: (dataTransfer.files || []).length, }); const results = []; const seenKeys = new Set(); const pushFile = (file, ancestors = []) => { if (!file) return; const segments = (ancestors || []).filter(Boolean); const key = `${segments.join('/')}/${file.name}:${file.size}`; if (seenKeys.has(key)) { return; } seenKeys.add(key); results.push({ file, segments }); }; const readAllEntries = async (reader) => { const entries = []; let batch = []; do { batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (batch.length) { entries.push(...batch); } } while (batch.length); return entries; }; const walkEntry = async (entry, ancestors = []) => { if (!entry) return; if (entry.isFile) { const file = await new Promise((resolve, reject) => { try { entry.file(resolve, reject); } catch (error) { console.warn('[Uploads] entry.file failed', error); reject(error); } }); pushFile(file, ancestors); return; } if (entry.isDirectory) { const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; const reader = entry.createReader(); const entries = await readAllEntries(reader); for (const child of entries) { await walkEntry(child, nextAncestors); } } }; await Promise.all( items.map(async (item, index) => { if (item.kind !== 'file') return; const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null; if (fileFromItem) { const relativePath = fileFromItem?.webkitRelativePath ?? ''; const segments = relativePath ? relativePath .split('/') .slice(0, -1) .filter(Boolean) : []; pushFile(fileFromItem, segments); } if (typeof item.webkitGetAsEntry === 'function') { try { const entry = item.webkitGetAsEntry(); if (entry) { await walkEntry(entry, []); return; } } catch (error) { console.warn('[Uploads] webkitGetAsEntry failed', error); } } if (!fileFromItem) { console.info('[Uploads] item missing file handle', index); } }), ); Array.from(dataTransfer.files || []).forEach((file) => { if (!file) return; const relativePath = file?.webkitRelativePath ?? ''; const segments = relativePath ? relativePath .split('/') .slice(0, -1) .filter(Boolean) : []; pushFile(file, segments); }); if (!results.length) { throw new Error('No files detected in drop payload.'); } console.info('[Uploads] prepared files', results.length); return results; }, []); const uploadFileEntries = useCallback( async (entries, targetFolderId) => { if (!entries || !entries.length) { console.warn('[Uploads] No files to upload.'); return; } const queueItems = appendQueueItems(entries, targetFolderId); if (!token) { queueItems.forEach((item) => { const patch = { status: 'error', error: 'Please log in before uploading.', code: null, }; updateQueueItem(item.id, patch); Object.assign(item, patch); }); return; } setLoading(true); try { folderPathCacheRef.current.clear(); const baseFolderId = targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; for (let index = 0; index < entries.length; index += 1) { const { file, segments } = entries[index]; const queueItem = queueItems[index]; if (queueItem) { const patch = { status: 'uploading', error: null, code: null }; updateQueueItem(queueItem.id, patch); Object.assign(queueItem, patch); } const destinationId = segments.length ? await ensureFolderPathOnServer(baseFolderId, segments) : baseFolderId; const uploadTarget = destinationId ?? (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); try { const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile( file, uploadTarget, ); if (queueItem) { const patch = { status: duplicate ? 'duplicate' : 'success', code: statusCode ?? null, document: document || queueItem.document, conflictDocumentId: conflictDocumentId ?? queueItem.conflictDocumentId, }; updateQueueItem(queueItem.id, patch); Object.assign(queueItem, patch); } } catch (error) { if (queueItem) { const patch = { status: 'error', error: error.response?.data?.error || error.message || 'Upload failed.', code: error.response?.status ?? null, }; updateQueueItem(queueItem.id, patch); Object.assign(queueItem, patch); } continue; } } await refreshCurrentFolder(); if ( targetFolderId && targetFolderId !== 'root' && targetFolderId !== selectedFolder ) { await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); } } catch (error) { const message = error.message || 'Failed to upload files.'; queueItems.forEach((item) => { if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') { return; } const patch = { status: 'error', error: message, code: error.response?.status ?? null, }; updateQueueItem(item.id, patch); Object.assign(item, patch); }); console.error('[Uploads] batch failed', error); } finally { setLoading(false); } }, [ token, ensureFolderPathOnServer, uploadFile, refreshCurrentFolder, selectedFolder, ensureFolderData, setLoading, appendQueueItems, updateQueueItem, ], ); const handleFileDrop = useCallback( async (dataTransfer, targetFolderId) => { let extracted; try { extracted = await extractFilesFromDataTransfer(dataTransfer); } catch (error) { console.error('[Uploads] Failed to process dropped files.', error); return; } await uploadFileEntries(extracted, targetFolderId); }, [extractFilesFromDataTransfer, uploadFileEntries], ); const handleFileSelection = useCallback( async (files, targetFolderId) => { const entries = mapFilesToEntries(files); await uploadFileEntries(entries, targetFolderId); }, [uploadFileEntries], ); useFileDrop({ shellRef, token, currentFolderName, selectedFolder, handleFileDrop, hasFiles, defaultFolderName: DEFAULT_FOLDER_NAME, dragCounterRef, dropOverlayState, setDropOverlayState, }); const resetUploadsState = useCallback(() => { dragCounterRef.current = 0; setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setUploadQueue([]); }, []); const clearUploadQueue = useCallback(() => { setUploadQueue([]); }, []); return { dropOverlayState, setDropOverlayState, dragCounterRef, handleFileDrop, handleFileSelection, uploadFile, extractFilesFromDataTransfer, resetUploadsState, uploadQueue, clearUploadQueue, }; }; export default useDocumentUploads;