Files
papercrate/frontend/src/documents/data/useDocumentMutations.ts
T

723 lines
22 KiB
TypeScript

import { useCallback } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import { getEntryId, isDocumentEntry } from '../../app/entryKey';
import {
addDocumentTags,
createTag,
deleteDocumentTag,
deleteFolder,
moveDocumentsBulk,
moveDocumentToFolder,
queueDocumentReanalysis,
trashDocument,
updateDocument,
} from '../../lib/api/apiClient';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
import type { Document, MessageOptions } from '../../types/documents';
type FolderId = FolderIdentifier | 'root';
type NullableFolderId = FolderId | null;
type DocumentCacheMapper = (
doc: Document | null,
) => Document | null;
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
type UpdateDocumentCaches = (
documentId: DocumentId,
updater: DocumentCacheMapper,
) => void;
type EnsureFolderData = (
folderId: FolderId,
options?: { includeDocuments?: boolean },
) => Promise<FolderContents>;
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
type CloseDocumentPreview = () => void;
interface Tag {
id: DocumentId;
label: string;
color?: string | null;
[key: string]: unknown;
}
interface FolderContents {
documents?: Document[];
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<string, unknown>;
}
interface DocumentTagExtras {
option?: Tag | null;
input?: { value?: string } | null;
}
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseDocumentMutationsArgs {
token?: string | null;
documentLookup: Map<DocumentId, Document>;
folderLabelMap: Map<FolderId, string>;
ensureFolderData: EnsureFolderData;
selectedFolder: FolderId;
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
focusedDocumentId: DocumentId | null;
setFocusedEntryKey: Dispatch<SetStateAction<string | null>>;
focusedEntryKey: string | null;
mapDocumentCaches: MapDocumentCaches;
folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
closeDocumentPreview: CloseDocumentPreview;
previewDocumentId?: DocumentId | null;
refreshCurrentFolder: () => Promise<void>;
updateDocumentCaches: UpdateDocumentCaches;
tagLookupById: Map<DocumentId, Tag>;
tags: Tag[];
refreshTags: () => Promise<void>;
tagManager: TagManager;
extractDocumentFromResponse?: (payload: unknown) => Document | null;
ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
}
interface UseDocumentMutationsResult {
moveDocumentsToFolder: (
documentIds: Array<DocumentId | Document>,
targetFolderId?: NullableFolderId,
) => Promise<void>;
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
handleDocumentsDelete: (
documentIds: DocumentId[],
options?: MessageOptions,
) => Promise<boolean>;
handleDocumentTagAdd: (
document: Document,
label: string,
extras?: DocumentTagExtras | null,
) => Promise<void>;
handleDocumentTagAttach: (documentId: DocumentId, tagId: DocumentId) => Promise<boolean>;
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
handleDocumentIssuedUpdate: (
documentId: DocumentId,
nextIssuedDate: number | null,
) => Promise<boolean>;
handleTagRemove: (
documentId?: DocumentId,
tagId?: DocumentId,
) => Promise<boolean>;
handleFolderDelete: (folderId?: FolderId, options?: MessageOptions) => Promise<boolean>;
}
const normalizeDocumentId = (value: unknown): DocumentId | null => {
if (!value) return null;
if (value && typeof value === 'object' && 'id' in value && value.id != null) {
return value.id as DocumentId;
}
return value as DocumentId;
};
const useDocumentMutations = ({
token,
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSelectedFolder,
setDocuments,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
mapDocumentCaches,
folderNodes,
setFolderNodes,
removeDocumentsFromCaches,
closeDocumentPreview,
previewDocumentId,
refreshCurrentFolder,
updateDocumentCaches,
tagLookupById,
tags,
refreshTags,
tagManager,
extractDocumentFromResponse,
ingestDocuments,
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const moveDocumentsToFolder = useCallback(
async (documentIds: Array<DocumentId | Document>, 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: Document }>;
const updatedDocsMap = new Map<DocumentId, Document>();
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: Document = {
...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 (!isDocumentEntry(key)) {
return true;
}
const id = getEntryId(key);
return id ? !uniqueIdSet.has(id as DocumentId) : true;
});
try {
if (uniqueIds.length === 1) {
await moveDocumentToFolder(uniqueIds[0], target);
} else {
await moveDocumentsBulk(uniqueIds, target);
}
const count = uniqueIds.length;
const suffix = count === 1 ? '' : 's';
showToast(`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) {
setSearchResultIds((prev) => {
if (!Array.isArray(prev) || !prev.length) {
return prev;
}
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
// setFolderContents removed as we don't hold full cache anymore
setSelectedEntries((prev) => pruneRow(prev));
setSelectionOrder((prev) => pruneRow(prev));
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
selectionOrderRef.current = nextSelectionOrder;
if (
selectionAnchorRef.current &&
isDocumentEntry(selectionAnchorRef.current) &&
uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId)
) {
selectionAnchorRef.current = null;
}
if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) {
setFocusedDocumentId(null);
}
if (
focusedEntryKey &&
isDocumentEntry(focusedEntryKey) &&
uniqueIdSet.has(getEntryId(focusedEntryKey) as DocumentId)
) {
setFocusedEntryKey(null);
}
}
if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId as FolderId);
}
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
notifyApiError(error, message);
}
},
[
documentLookup,
folderLabelMap,
ensureFolderData,
selectedFolder,
setSearchResultIds,
setDocuments,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
setFocusedDocumentId,
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
notifyApiError,
showToast,
mapDocumentCaches,
],
);
const handleThumbnailRegeneration = useCallback(
async (documentId: DocumentId) => {
if (!token) {
showToast('Log in to manage assets.', 'error');
return;
}
try {
await queueDocumentReanalysis(documentId, { force: true });
showToast('Document re-analysis queued.', 'info');
await refreshCurrentFolder();
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
notifyApiError(error, message);
}
},
[token, refreshCurrentFolder, notifyApiError, showToast],
);
const handleDocumentsDelete = useCallback(
async (documentIds: DocumentId[], { showMessage = true }: MessageOptions = {}) => {
if (!documentIds || documentIds.length === 0) {
return false;
}
if (!token) {
showToast('Log in to manage documents.', 'error');
return false;
}
try {
await Promise.all(documentIds.map((documentId) => trashDocument(documentId)));
removeDocumentsFromCaches(documentIds);
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
closeDocumentPreview();
}
if (showMessage) {
const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.';
showToast(message, 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
notifyApiError(error, message);
return false;
}
},
[
token,
removeDocumentsFromCaches,
previewDocumentId,
closeDocumentPreview,
notifyApiError,
showToast,
],
);
const handleDocumentTitleUpdate = useCallback(
async (documentId: DocumentId, nextTitle: string) => {
const trimmed = nextTitle?.trim?.() || '';
if (!trimmed) {
showToast('Document title cannot be empty.', 'error');
return false;
}
try {
const data = await updateDocument(documentId, { title: trimmed });
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
} else {
updateDocumentCaches(documentId, (doc) => {
if (updatedDocument) {
return { ...doc, ...updatedDocument };
}
return { ...doc, title: trimmed };
});
}
showToast('Document title updated.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
notifyApiError(error, message);
return false;
}
},
[
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
showToast,
updateDocumentCaches,
],
);
const handleDocumentIssuedUpdate = useCallback(
async (documentId: DocumentId, nextIssuedDate: number | null) => {
const payload = { issued_at: nextIssuedDate || null };
try {
const data = await updateDocument(documentId, payload);
const updatedDocument = extractDocumentFromResponse?.(data);
if (updatedDocument && ingestDocuments) {
ingestDocuments([updatedDocument]);
} else {
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.';
showToast(message, 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
notifyApiError(error, message);
return false;
}
},
[
extractDocumentFromResponse,
ingestDocuments,
notifyApiError,
showToast,
updateDocumentCaches,
],
);
const attachTagToDocument = useCallback(
async ({
documentId,
tag,
}: {
documentId?: DocumentId;
tag?: Tag | null;
}) => {
if (!documentId || !tag?.id) {
return false;
}
const cachedTag: Tag = {
id: tag.id,
label: tag.label,
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
};
try {
await addDocumentTags(documentId, [cachedTag.id]);
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
return doc;
}
return { ...doc, tags: [...currentTags, cachedTag] };
});
showToast('Tag assigned.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
return false;
}
},
[notifyApiError, showToast, updateDocumentCaches],
);
const handleDocumentTagAdd = useCallback(
async (document: Document, 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 }) as { label: string; color?: string | null };
const data = await createTag(payload);
tag = data as Tag;
await refreshTags();
}
await attachTagToDocument({
documentId: document.id as DocumentId,
tag,
});
if (input && Object(input) === input && 'value' in (input as Record<string, unknown>)) {
(input as { value?: string }).value = '';
}
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
}
},
[tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
);
const handleDocumentTagAttach = useCallback(
async (documentId: DocumentId, tagId: DocumentId) => {
if (!documentId || !tagId) {
return false;
}
const resolveTagForCache = (): Tag | null => {
const lookupTag = tagLookupById.get(tagId);
if (!lookupTag || lookupTag.id == null) {
return null;
}
const labelText = `${lookupTag.label ?? ''} `.trim();
if (!labelText) {
return null;
}
return {
id: lookupTag.id,
label: labelText,
color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null,
};
};
const resolvedTag = resolveTagForCache();
return attachTagToDocument({
documentId,
tag: resolvedTag,
});
},
[
attachTagToDocument,
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,
) => {
if (!documentId || !tagId) {
return false;
}
try {
await deleteDocumentTag(documentId, tagId);
applyTagRemovalToCaches(documentId, tagId);
showToast('Tag removed.', 'success');
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
return false;
}
},
[applyTagRemovalToCaches, notifyApiError, showToast],
);
const handleFolderDelete = useCallback(
async (folderId?: FolderId, { showMessage = true }: MessageOptions = {}) => {
if (!token) {
if (showMessage) {
showToast('Log in to manage folders.', 'error');
}
return false;
}
if (!folderId || folderId === 'root') {
if (showMessage) {
showToast('The root folder cannot be removed.', 'error');
}
return false;
}
try {
const contents = await ensureFolderData(folderId);
const hasChildren = (contents.subfolders || []).length > 0;
const hasDocs = (contents.documents || []).length > 0;
if (hasChildren || hasDocs) {
if (showMessage) {
showToast('Folder must be empty before it can be deleted.', 'error');
}
return false;
}
await deleteFolder(folderId);
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
const next = new Map<FolderId, FolderNode>(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,
});
}
}
});
// setFolderContents removed
if (selectedFolder === folderId) {
const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root';
setSelectedFolder(parentId);
// ensureFolderData(parentId) will be called by useDocumentsWorkspace effect when selectedFolder changes
} else if (selectedFolder !== 'root') {
// If deleted folder was not selected, just check if we need to refresh (maybe redundant)
await ensureFolderData(selectedFolder);
}
if (showMessage) {
showToast('Folder deleted.', 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
notifyApiError(error, message);
if (showMessage) {
showToast(message, 'error');
}
return false;
}
},
[
token,
ensureFolderData,
selectedFolder,
folderNodes,
setSelectedFolder,
setFolderNodes,
notifyApiError,
showToast,
],
);
return {
moveDocumentsToFolder,
handleThumbnailRegeneration,
handleDocumentsDelete,
handleDocumentTagAdd,
handleDocumentTagAttach,
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleTagRemove,
handleFolderDelete,
};
};
export default useDocumentMutations;