import { useCallback } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils'; type DocumentId = string | number; type FolderId = DocumentId | 'root'; type NullableFolderId = FolderId | null; type StatusLevel = 'success' | 'error' | 'info' | string; type DocumentCacheMapper = ( doc: DocumentLike | null | undefined, ) => DocumentLike | null | undefined; type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; type UpdateDocumentCaches = ( documentId: DocumentId, updater: DocumentCacheMapper, ) => void; type EnsureFolderData = ( folderId: FolderId, options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number }, ) => Promise; type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void; type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void; type CloseDocumentPreview = () => void; type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; type SetStatusMessage = (message: string, level?: StatusLevel) => void; interface ApiClient { post(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; patch(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; delete(url: string, config?: Record): Promise<{ data: T }>; } interface Tag { id: DocumentId; label: string; color?: string | null; [key: string]: unknown; } interface DocumentLike { id?: DocumentId; folder_id?: NullableFolderId; folder_path?: string | null; folder_name?: string | null; issued_at?: number | null; title?: string; tags?: Tag[]; [key: string]: unknown; } interface FolderContents { documents?: DocumentLike[]; subfolders?: Array<{ id?: FolderId; [key: string]: unknown }>; [key: string]: unknown; } interface FolderNode { id: FolderId; parentId?: FolderId; children: FolderId[]; hasChildren?: boolean; [key: string]: unknown; } interface TagManager { normalizeLabel: (label: string) => string; buildPayload: (args: { label: string }) => Record; } interface DocumentTagExtras { option?: Tag | null; input?: { value?: string } | null; } interface DeleteOptions { showMessage?: boolean; manageLoading?: boolean; } interface TagAttachArgs { documentId?: DocumentId; tagId?: DocumentId; tag?: Tag | null; } interface TagRemoveOptions { refreshTagList?: boolean; showMessage?: boolean; } interface FolderDeleteOptions { showMessage?: boolean; manageLoading?: boolean; } interface UseDocumentMutationsArgs { api: ApiClient; token?: string | null; documentLookup: Map; folderLabelMap: Map; ensureFolderData: EnsureFolderData; selectedFolder: FolderId; setSelectedFolder: Dispatch>; setDocuments: Dispatch>; setFolderContents: Dispatch>>; setSearchResults: Dispatch>; setSelectedEntries: Dispatch>; setSelectionOrder: Dispatch>; selectionOrderRef: MutableRefObject; selectionAnchorRef: MutableRefObject; setFocusedDocumentId: Dispatch>; focusedDocumentId: DocumentId | null; setFocusedRowKey: Dispatch>; focusedRowKey: string | null; notifyApiError: NotifyApiError; setStatusMessage: SetStatusMessage; setLoading: (next: boolean) => void; mapDocumentCaches: MapDocumentCaches; applySelectedFolder: ApplySelectedFolder; folderNodes: Map; setFolderNodes: Dispatch>>; removeDocumentsFromCaches: RemoveDocumentsFromCaches; closeDocumentPreview: CloseDocumentPreview; previewDocumentId?: DocumentId | null; refreshCurrentFolder: () => Promise; documentsViewMode?: string; updateDocumentCaches: UpdateDocumentCaches; tagLookupById: Map; tags: Tag[]; refreshTags: () => Promise; tagManager: TagManager; extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null | undefined; } interface UseDocumentMutationsResult { moveDocumentsToFolder: ( documentIds: Array, targetFolderId?: NullableFolderId, ) => Promise; handleThumbnailRegeneration: (documentId: DocumentId) => Promise; handleDocumentsDelete: ( documentIds: DocumentId[], options?: DeleteOptions, ) => Promise; handleDocumentTagAdd: ( document: DocumentLike, label: string, extras?: DocumentTagExtras | null, ) => Promise; handleDocumentTagAttach: (args: TagAttachArgs) => Promise; handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise; handleDocumentIssuedUpdate: ( documentId: DocumentId, nextIssuedDate: number | null, ) => Promise; handleTagRemove: ( documentId?: DocumentId, tagId?: DocumentId, options?: TagRemoveOptions, ) => Promise; handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise; } const normalizeDocumentId = (value: unknown): DocumentId | null => { if (!value) return null; if (typeof value === 'object' && value !== null && 'id' in value && value.id != null) { return value.id as DocumentId; } return value as DocumentId; }; const useDocumentMutations = ({ api, token, documentLookup, folderLabelMap, ensureFolderData, selectedFolder, setSelectedFolder, setDocuments, setFolderContents, setSearchResults, setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, setFocusedRowKey, focusedRowKey, notifyApiError, setStatusMessage, setLoading, mapDocumentCaches, applySelectedFolder, folderNodes, setFolderNodes, removeDocumentsFromCaches, closeDocumentPreview, previewDocumentId, refreshCurrentFolder, documentsViewMode, updateDocumentCaches, tagLookupById, tags, refreshTags, tagManager, extractDocumentFromResponse, }: UseDocumentMutationsArgs): UseDocumentMutationsResult => { const moveDocumentsToFolder = useCallback( async (documentIds: Array, targetFolderId?: NullableFolderId) => { const uniqueIds = Array.from( new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]), ); if (!uniqueIds.length) return; const uniqueIdSet = new Set(uniqueIds); const target = targetFolderId === 'root' ? null : targetFolderId ?? null; const targetLabel = target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder'; const movedDocs = uniqueIds .map((id) => { const doc = documentLookup.get(id) || null; if (!doc) { return null; } return { id, sourceFolderId: (doc.folder_id ?? null) as NullableFolderId, document: doc, }; }) .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>; const updatedDocsMap = new Map(); const resolveTargetName = () => { if (!targetLabel) { return null; } const segments = String(targetLabel).split('/'); return segments[segments.length - 1] || targetLabel; }; const targetName = resolveTargetName(); movedDocs.forEach(({ id, document }) => { if (!document) { return; } const updated: DocumentLike = { ...document, folder_id: target, }; if (targetLabel) { updated.folder_path = targetLabel; if (targetName) { updated.folder_name = targetName; } } else if (target === null) { updated.folder_path = DEFAULT_FOLDER_NAME; updated.folder_name = DEFAULT_FOLDER_NAME; } updatedDocsMap.set(id, updated); }); const pruneRow = (collection: string[]): string[] => collection.filter((key) => { if (!isDocumentRowKey(key)) { return true; } const id = getRowId(key); return id ? !uniqueIdSet.has(id as DocumentId) : true; }); setLoading(true); try { if (uniqueIds.length === 1) { await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target }); } else { await api.post('/documents/bulk/move', { document_ids: uniqueIds, folder_id: target, }); } const count = uniqueIds.length; const suffix = count === 1 ? '' : 's'; setStatusMessage(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); if (updatedDocsMap.size) { mapDocumentCaches((doc) => { if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { return doc; } const updated = updatedDocsMap.get(doc.id as DocumentId); if (updated) { return updated; } return { ...doc, folder_id: target }; }); } else { mapDocumentCaches((doc) => { if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { return doc; } return { ...doc, folder_id: target }; }); } if (uniqueIdSet.size) { setSearchResults((prev) => { if (!Array.isArray(prev) || !prev.length) { return prev; } const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id as DocumentId)); return filtered.length === prev.length ? prev : filtered; }); setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId))); setFolderContents((prev: Map) => { if (!prev.size) { return prev; } let changed = false; const next = new Map(prev); movedDocs.forEach(({ id, sourceFolderId }) => { const sourceKey = (sourceFolderId || 'root') as FolderId; const entry = next.get(sourceKey); if (!entry?.documents?.length) { return; } const filteredDocs = entry.documents.filter((doc) => doc.id !== id); if (filteredDocs.length !== entry.documents.length) { changed = true; next.set(sourceKey, { ...entry, documents: filteredDocs }); } }); return changed ? next : prev; }); setSelectedEntries((prev) => pruneRow(prev)); setSelectionOrder((prev) => pruneRow(prev)); const nextSelectionOrder = pruneRow(selectionOrderRef.current || []); selectionOrderRef.current = nextSelectionOrder; if ( selectionAnchorRef.current && isDocumentRowKey(selectionAnchorRef.current) && uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId) ) { selectionAnchorRef.current = null; } if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) { setFocusedDocumentId(null); } if ( focusedRowKey && isDocumentRowKey(focusedRowKey) && uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId) ) { setFocusedRowKey(null); } } if (targetFolderId && targetFolderId !== selectedFolder) { await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 }); } } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to move documents.'; notifyApiError(error, message); } finally { setLoading(false); } }, [ api, documentLookup, folderLabelMap, ensureFolderData, selectedFolder, setSearchResults, setDocuments, setFolderContents, setSelectedEntries, setSelectionOrder, selectionOrderRef, selectionAnchorRef, setFocusedDocumentId, focusedDocumentId, setFocusedRowKey, focusedRowKey, notifyApiError, setStatusMessage, setLoading, mapDocumentCaches, ], ); const handleThumbnailRegeneration = useCallback( async (documentId: DocumentId) => { if (!token) { setStatusMessage('Log in to manage assets.', 'error'); return; } setLoading(true); try { await api.post(`/documents/${documentId}/assets`, null, { params: { force: true }, }); setStatusMessage('Document re-analysis queued.', 'info'); await refreshCurrentFolder(); } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to request thumbnail generation.'; notifyApiError(error, message); } finally { setLoading(false); } }, [api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading], ); const handleDocumentsDelete = useCallback( async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => { if (!documentIds || documentIds.length === 0) { return false; } if (!token) { setStatusMessage('Log in to manage documents.', 'error'); return false; } if (manageLoading) { setLoading(true); } try { await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`))); removeDocumentsFromCaches(documentIds); if (previewDocumentId && documentIds.includes(previewDocumentId)) { closeDocumentPreview(); } if (showMessage) { const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.'; setStatusMessage(message, 'success'); } return true; } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to delete documents.'; notifyApiError(error, message); return false; } finally { if (manageLoading) { setLoading(false); } } }, [ api, token, removeDocumentsFromCaches, previewDocumentId, closeDocumentPreview, notifyApiError, setStatusMessage, setLoading, ], ); const handleDocumentTitleUpdate = useCallback( async (documentId: DocumentId, nextTitle: string) => { const trimmed = nextTitle?.trim?.() || ''; if (!trimmed) { setStatusMessage('Document title cannot be empty.', 'error'); return false; } setLoading(true); try { const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); const updatedDocument = extractDocumentFromResponse?.(data); updateDocumentCaches(documentId, (doc) => { if (updatedDocument) { return { ...doc, ...updatedDocument }; } return { ...doc, title: trimmed }; }); setStatusMessage('Document title updated.', 'success'); return true; } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to update document title.'; notifyApiError(error, message); return false; } finally { setLoading(false); } }, [api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches], ); const handleDocumentIssuedUpdate = useCallback( async (documentId: DocumentId, nextIssuedDate: number | null) => { setLoading(true); const payload = { issued_at: nextIssuedDate || null }; try { const { data } = await api.patch(`/documents/${documentId}`, payload); const updatedDocument = extractDocumentFromResponse?.(data); updateDocumentCaches(documentId, (doc) => { if (updatedDocument) { return { ...doc, ...updatedDocument }; } return { ...doc, issued_at: payload.issued_at }; }); const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; setStatusMessage(message, 'success'); return true; } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to update issued date.'; notifyApiError(error, message); return false; } finally { setLoading(false); } }, [api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches], ); const handleDocumentTagAdd = useCallback( async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => { const normalizedLabel = tagManager.normalizeLabel(label); const optionCandidate = extras?.option ?? null; const input = extras?.input ?? null; let tag: Tag | null = null; if (optionCandidate && optionCandidate.id) { tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate; } if (!tag) { tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; } try { if (!tag) { const payload = tagManager.buildPayload({ label: normalizedLabel }); const { data } = await api.post('/tags', payload); tag = data as Tag; await refreshTags(); } await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); setStatusMessage('Tag assigned.', 'success'); if (input && typeof input === 'object') { input.value = ''; } await refreshCurrentFolder(); } catch (error) { notifyApiError(error, 'Failed to assign tag.'); } }, [api, tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager], ); const handleDocumentTagAttach = useCallback( async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => { if (!documentId || !tagId) { return false; } const resolveTagForCache = (): Tag | null => { const lookupTag = tagLookupById.get(tagId); const source = lookupTag ?? tagData; if (!source || source.id == null || typeof source.label?.trim !== 'function') { return null; } return { id: source.id, label: source.label, color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source as Tag).color ?? null : null, }; }; try { await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); updateDocumentCaches(documentId, (doc) => { if (!doc) { return doc; } const currentTags = Array.isArray(doc.tags) ? doc.tags : []; if (currentTags.some((existing) => existing?.id === tagId)) { return doc; } const resolvedTag = resolveTagForCache(); if (!resolvedTag) { return doc; } return { ...doc, tags: [...currentTags, resolvedTag] }; }); setStatusMessage('Tag assigned.', 'success'); if (documentsViewMode !== 'desk') { await refreshCurrentFolder(); } return true; } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; notifyApiError(error, message); return false; } }, [ api, refreshCurrentFolder, documentsViewMode, notifyApiError, setStatusMessage, updateDocumentCaches, tagLookupById, ], ); const applyTagRemovalToCaches = useCallback( (documentId?: DocumentId, tagId?: DocumentId) => { if (!documentId || !tagId) { return; } updateDocumentCaches(documentId, (doc) => { if (!doc || !Array.isArray(doc.tags)) { return doc; } const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId); if (nextTags.length === doc.tags.length) { return doc; } return { ...doc, tags: nextTags }; }); }, [updateDocumentCaches], ); const handleTagRemove = useCallback( async ( documentId?: DocumentId, tagId?: DocumentId, { refreshTagList = true, showMessage = true }: TagRemoveOptions = {}, ) => { if (!documentId || !tagId) { return false; } try { await api.delete(`/documents/${documentId}/tags/${tagId}`); applyTagRemovalToCaches(documentId, tagId); if (refreshTagList) { await refreshTags(); } if (showMessage) { setStatusMessage('Tag removed.', 'success'); } return true; } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to remove tag.'; notifyApiError(error, message); return false; } }, [api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage], ); const handleFolderDelete = useCallback( async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => { if (!token) { if (showMessage) { setStatusMessage('Log in to manage folders.', 'error'); } return false; } if (!folderId || folderId === 'root') { if (showMessage) { setStatusMessage('The root folder cannot be removed.', 'error'); } return false; } if (manageLoading) { setLoading(true); } try { const contents = await ensureFolderData(folderId, { force: true, prefetchDepth: 1, }); const hasChildren = (contents.subfolders || []).length > 0; const hasDocs = (contents.documents || []).length > 0; if (hasChildren || hasDocs) { if (showMessage) { setStatusMessage('Folder must be empty before it can be deleted.', 'error'); } return false; } await api.delete(`/folders/${folderId}`); setFolderNodes((prev: Map) => { const next = new Map(prev); const node = next.get(folderId); next.delete(folderId); if (node) { const parentId = node.parentId || 'root'; const parentNode = next.get(parentId); if (parentNode) { const remaining = parentNode.children.filter((id) => id !== folderId); next.set(parentId, { ...parentNode, children: remaining, hasChildren: remaining.length > 0, }); } } return next; }); setFolderContents((prev: Map) => { const next = new Map(prev); next.delete(folderId); return next; }); if (selectedFolder === folderId) { const node = folderNodes.get(folderId); const parentId = node?.parentId || 'root'; setSelectedFolder(parentId); const parentContents = await ensureFolderData(parentId, { force: true, prefetchDepth: 1, }); applySelectedFolder(parentId, parentContents); } else if (selectedFolder !== 'root') { await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); } if (showMessage) { setStatusMessage('Folder deleted.', 'success'); } return true; } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to delete folder.'; notifyApiError(error, message); if (showMessage) { setStatusMessage(message, 'error'); } return false; } finally { if (manageLoading) { setLoading(false); } } }, [ api, token, ensureFolderData, selectedFolder, folderNodes, setSelectedFolder, applySelectedFolder, setFolderNodes, setFolderContents, notifyApiError, setStatusMessage, setLoading, ], ); return { moveDocumentsToFolder, handleThumbnailRegeneration, handleDocumentsDelete, handleDocumentTagAdd, handleDocumentTagAttach, handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleTagRemove, handleFolderDelete, }; }; export default useDocumentMutations;