Files
papercrate/frontend/src/hooks/documents/useDocumentUploads.js
T
2025-11-12 17:02:27 +01:00

441 lines
13 KiB
JavaScript

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;