Files
papercrate/frontend/src/hooks/documents/useDocumentUploads.ts
T

535 lines
16 KiB
TypeScript

import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/workspaceUtils';
import { fetchDocument, uploadDocument, resolveFolderPath } from '../../lib/apiClient';
import type { Identifier } from '../../types/identifiers';
type FolderId = Identifier | 'root' | null;
type FileEntry = {
file: File;
segments: string[];
};
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error';
type UploadQueueItem = {
id: string;
name: string;
size: number | null;
folderId: FolderId;
status: UploadStatus;
error: string | null;
code: number | null;
document: unknown;
conflictDocumentId: Identifier | null;
};
type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
type DropOverlayState = {
active: boolean;
folderName: string;
};
type FileSystemEntryLike = FileSystemEntry;
type ExtendedDataTransferItem = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntry | null;
};
interface FileSystemDirectoryReaderLike {
readEntries: (
successCallback: (entries: FileSystemEntryLike[]) => void,
errorCallback: (error: DOMException) => void,
) => void;
}
interface FileSystemFileEntryLike extends FileSystemEntry {
isFile: true;
isDirectory: false;
name: string;
file: (
successCallback: (file: File) => void,
errorCallback: (error: DOMException) => void,
) => void;
}
interface FileSystemDirectoryEntryLike extends FileSystemEntry {
isFile: false;
isDirectory: true;
name: string;
createReader: () => FileSystemDirectoryReaderLike;
}
const isFileEntry = (entry: FileSystemEntryLike): entry is FileSystemFileEntryLike => {
return entry.isFile && !entry.isDirectory;
};
const isDirectoryEntry = (entry: FileSystemEntryLike): entry is FileSystemDirectoryEntryLike => {
return entry.isDirectory && !entry.isFile && 'createReader' in entry;
};
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
if (!filesInput) {
return [];
}
const files = Array.isArray(filesInput) ? filesInput : Array.from(filesInput);
return files
.filter(Boolean)
.map((file) => {
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
.slice(0, -1)
.filter(Boolean)
: [];
return { file, segments };
});
};
interface UseDocumentUploadsArgs {
token?: string | null;
selectedFolder?: FolderId;
currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError;
setStatusMessage?: SetStatusMessage;
}
interface UseDocumentUploadsResult {
dropOverlayState: DropOverlayState;
setDropOverlayState: Dispatch<SetStateAction<DropOverlayState>>;
dragCounterRef: MutableRefObject<number>;
handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise<void>;
handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise<void>;
uploadFile: (file: File, targetFolderId: FolderId) => Promise<{
document: unknown;
duplicate: boolean;
statusCode: number | null;
conflictDocumentId: Identifier | null;
}>;
extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise<FileEntry[]>;
resetUploadsState: () => void;
uploadQueue: UploadQueueItem[];
clearUploadQueue: () => void;
}
const useDocumentUploads = ({
token,
selectedFolder,
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
shellRef,
notifyApiError,
setStatusMessage,
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false,
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
});
const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const uploadFile = useCallback(
async (file: File, targetFolderId: FolderId) => {
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 != null && targetFolderId !== 'root') {
formData.append('folder_id', String(targetFolderId));
}
try {
const { reused, document, status } = await uploadDocument(formData);
const duplicate = reused || status === 200;
return {
document: document ?? null,
duplicate,
statusCode: status ?? (duplicate ? 200 : 201),
conflictDocumentId: null,
};
} catch (error: any) {
if (error.response?.status === 409) {
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
let conflictDocument = null;
if (conflictId) {
try {
conflictDocument = await fetchDocument(conflictId);
} 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}.`;
notifyApiError?.(error, message);
setStatusMessage?.(message, 'error');
const wrapped = Object.assign(new Error(message), { response: error.response });
throw wrapped;
}
},
[notifyApiError, setStatusMessage],
);
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
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' as UploadStatus,
error: null,
code: null,
document: null,
conflictDocumentId: null,
} satisfies UploadQueueItem;
});
if (items.length) {
setUploadQueue((current) => [...current, ...items]);
}
return items;
}, [selectedFolder]);
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
if (!id) {
return;
}
setUploadQueue((current) =>
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
}, []);
const ensureFolderPathOnServer = useCallback(
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
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) ?? null;
}
const payload = {
parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null,
segments: trimmedSegments,
};
const { folder } = await resolveFolderPath(payload);
const resolvedId = (folder?.id ?? null) as FolderId;
cache.set(cacheKey, resolvedId);
return resolvedId;
},
[],
);
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
if (!dataTransfer) {
throw new Error('No drop payload found.');
}
const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[];
console.info('[Uploads] drop start', {
items: items.length,
files: (dataTransfer.files || []).length,
});
const results: FileEntry[] = [];
const seenKeys = new Set();
const pushFile = (file?: File | null, ancestors: string[] = []) => {
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: FileSystemDirectoryReaderLike) => {
const entries: FileSystemEntryLike[] = [];
let batch: FileSystemEntryLike[] = [];
do {
batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (batch.length) {
entries.push(...batch);
}
} while (batch.length);
return entries;
};
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
if (!entry) return;
if (isFileEntry(entry)) {
const file = await new Promise<File>((resolve, reject) => {
try {
entry.file(resolve, reject);
} catch (error) {
console.warn('[Uploads] entry.file failed', error);
reject(error as Error);
}
});
pushFile(file, ancestors);
return;
}
if (isDirectoryEntry(entry)) {
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 = item.getAsFile?.() ?? null;
if (fileFromItem) {
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
const segments = relativePath
? relativePath
.split('/')
.slice(0, -1)
.filter(Boolean)
: [];
pushFile(fileFromItem, segments);
}
if ((item as ExtendedDataTransferItem).webkitGetAsEntry) {
try {
const entry = (item as ExtendedDataTransferItem).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 as File & { webkitRelativePath?: string })?.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;
}
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: any) {
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: any) {
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);
}
},
[
token,
ensureFolderPathOnServer,
uploadFile,
refreshCurrentFolder,
selectedFolder,
ensureFolderData,
appendQueueItems,
updateQueueItem,
],
);
const handleFileDrop = useCallback(
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
let extracted: FileEntry[];
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?: FileList | null, targetFolderId?: FolderId) => {
const entries = mapFilesToEntries(files);
await uploadFileEntries(entries, targetFolderId);
},
[uploadFileEntries],
);
useFileDrop({
shellRef,
token,
currentFolderName,
selectedFolder,
handleFileDrop,
hasFiles,
defaultFolderName: DEFAULT_FOLDER_NAME,
dragCounterRef,
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,
} satisfies UseDocumentUploadsResult;
};
export default useDocumentUploads;