800 lines
24 KiB
TypeScript
800 lines
24 KiB
TypeScript
import { useCallback } from 'react';
|
|
import { isPlainObject } from '../../utils/typeGuards';
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
|
|
import {
|
|
addDocumentTags,
|
|
createTag,
|
|
deleteDocumentTag,
|
|
deleteFolder,
|
|
moveDocumentsBulk,
|
|
moveDocumentToFolder,
|
|
queueDocumentReanalysis,
|
|
trashDocument,
|
|
updateDocument,
|
|
} from '../../lib/apiClient';
|
|
|
|
type DocumentId = string | number;
|
|
type FolderId = DocumentId | 'root';
|
|
type NullableFolderId = FolderId | null;
|
|
|
|
type StatusLevel = 'success' | 'error' | 'info' | string;
|
|
|
|
type DocumentCacheMapper = (
|
|
doc: DocumentLike | null,
|
|
) => DocumentLike | null;
|
|
|
|
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
|
|
|
|
type UpdateDocumentCaches = (
|
|
documentId: DocumentId,
|
|
updater: DocumentCacheMapper,
|
|
) => void;
|
|
|
|
type EnsureFolderData = (
|
|
folderId: FolderId,
|
|
options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number },
|
|
) => Promise<FolderContents>;
|
|
|
|
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 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<string, unknown>;
|
|
}
|
|
|
|
interface DocumentTagExtras {
|
|
option?: Tag | null;
|
|
input?: { value?: string } | null;
|
|
}
|
|
|
|
interface DeleteOptions {
|
|
showMessage?: boolean;
|
|
}
|
|
|
|
interface TagAttachArgs {
|
|
documentId?: DocumentId;
|
|
tagId?: DocumentId;
|
|
tag?: Tag | null;
|
|
}
|
|
|
|
interface TagRemoveOptions {
|
|
refreshTagList?: boolean;
|
|
showMessage?: boolean;
|
|
}
|
|
|
|
interface FolderDeleteOptions {
|
|
showMessage?: boolean;
|
|
}
|
|
|
|
interface UseDocumentMutationsArgs {
|
|
token?: string | null;
|
|
documentLookup: Map<DocumentId, DocumentLike>;
|
|
folderLabelMap: Map<FolderId, string>;
|
|
ensureFolderData: EnsureFolderData;
|
|
selectedFolder: FolderId;
|
|
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
|
|
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
|
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
|
|
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;
|
|
setFocusedRowKey: Dispatch<SetStateAction<string | null>>;
|
|
focusedRowKey: string | null;
|
|
notifyApiError: NotifyApiError;
|
|
setStatusMessage: SetStatusMessage;
|
|
mapDocumentCaches: MapDocumentCaches;
|
|
applySelectedFolder: ApplySelectedFolder;
|
|
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) => DocumentLike | null;
|
|
ingestDocuments?: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
|
|
}
|
|
|
|
interface UseDocumentMutationsResult {
|
|
moveDocumentsToFolder: (
|
|
documentIds: Array<DocumentId | DocumentLike>,
|
|
targetFolderId?: NullableFolderId,
|
|
) => Promise<void>;
|
|
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
|
|
handleDocumentsDelete: (
|
|
documentIds: DocumentId[],
|
|
options?: DeleteOptions,
|
|
) => Promise<boolean>;
|
|
handleDocumentTagAdd: (
|
|
document: DocumentLike,
|
|
label: string,
|
|
extras?: DocumentTagExtras | null,
|
|
) => Promise<void>;
|
|
handleDocumentTagAttach: (args: TagAttachArgs) => Promise<boolean>;
|
|
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
|
|
handleDocumentIssuedUpdate: (
|
|
documentId: DocumentId,
|
|
nextIssuedDate: number | null,
|
|
) => Promise<boolean>;
|
|
handleTagRemove: (
|
|
documentId?: DocumentId,
|
|
tagId?: DocumentId,
|
|
options?: TagRemoveOptions,
|
|
) => Promise<boolean>;
|
|
handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise<boolean>;
|
|
}
|
|
|
|
const normalizeDocumentId = (value: unknown): DocumentId | null => {
|
|
if (!value) return null;
|
|
if (isPlainObject(value) && 'id' in value && value.id != null) {
|
|
return value.id as DocumentId;
|
|
}
|
|
return value as DocumentId;
|
|
};
|
|
|
|
const useDocumentMutations = ({
|
|
token,
|
|
documentLookup,
|
|
folderLabelMap,
|
|
ensureFolderData,
|
|
selectedFolder,
|
|
setSelectedFolder,
|
|
setDocuments,
|
|
setFolderContents,
|
|
setSearchResultIds,
|
|
setSelectedEntries,
|
|
setSelectionOrder,
|
|
selectionOrderRef,
|
|
selectionAnchorRef,
|
|
setFocusedDocumentId,
|
|
focusedDocumentId,
|
|
setFocusedRowKey,
|
|
focusedRowKey,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
mapDocumentCaches,
|
|
applySelectedFolder,
|
|
folderNodes,
|
|
setFolderNodes,
|
|
removeDocumentsFromCaches,
|
|
closeDocumentPreview,
|
|
previewDocumentId,
|
|
refreshCurrentFolder,
|
|
updateDocumentCaches,
|
|
tagLookupById,
|
|
tags,
|
|
refreshTags,
|
|
tagManager,
|
|
extractDocumentFromResponse,
|
|
ingestDocuments,
|
|
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
|
const moveDocumentsToFolder = useCallback(
|
|
async (documentIds: Array<DocumentId | DocumentLike>, 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<DocumentId, DocumentLike>();
|
|
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;
|
|
});
|
|
try {
|
|
if (uniqueIds.length === 1) {
|
|
await moveDocumentToFolder(uniqueIds[0], target);
|
|
} else {
|
|
await moveDocumentsBulk(uniqueIds, 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) {
|
|
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)));
|
|
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
|
if (!prev.size) {
|
|
return prev;
|
|
}
|
|
let changed = false;
|
|
const next = new Map<FolderId, FolderContents>(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<string, any>)?.response?.data?.error || 'Failed to move documents.';
|
|
notifyApiError(error, message);
|
|
}
|
|
},
|
|
[
|
|
documentLookup,
|
|
folderLabelMap,
|
|
ensureFolderData,
|
|
selectedFolder,
|
|
setSearchResultIds,
|
|
setDocuments,
|
|
setFolderContents,
|
|
setSelectedEntries,
|
|
setSelectionOrder,
|
|
selectionOrderRef,
|
|
selectionAnchorRef,
|
|
setFocusedDocumentId,
|
|
focusedDocumentId,
|
|
setFocusedRowKey,
|
|
focusedRowKey,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
mapDocumentCaches,
|
|
],
|
|
);
|
|
|
|
const handleThumbnailRegeneration = useCallback(
|
|
async (documentId: DocumentId) => {
|
|
if (!token) {
|
|
setStatusMessage('Log in to manage assets.', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
await queueDocumentReanalysis(documentId, { force: true });
|
|
setStatusMessage('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, setStatusMessage],
|
|
);
|
|
|
|
const handleDocumentsDelete = useCallback(
|
|
async (documentIds: DocumentId[], { showMessage = true }: DeleteOptions = {}) => {
|
|
if (!documentIds || documentIds.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (!token) {
|
|
setStatusMessage('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.';
|
|
setStatusMessage(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,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
const handleDocumentTitleUpdate = useCallback(
|
|
async (documentId: DocumentId, nextTitle: string) => {
|
|
const trimmed = nextTitle?.trim?.() || '';
|
|
if (!trimmed) {
|
|
setStatusMessage('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 };
|
|
});
|
|
}
|
|
|
|
setStatusMessage('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,
|
|
setStatusMessage,
|
|
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.';
|
|
setStatusMessage(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,
|
|
setStatusMessage,
|
|
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] };
|
|
});
|
|
setStatusMessage('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, 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 }) 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, 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) {
|
|
return null;
|
|
}
|
|
const labelText = `${source.label ?? ''}`.trim();
|
|
if (!labelText) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: source.id,
|
|
label: labelText,
|
|
color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source 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,
|
|
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
|
|
) => {
|
|
if (!documentId || !tagId) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await deleteDocumentTag(documentId, tagId);
|
|
applyTagRemovalToCaches(documentId, tagId);
|
|
if (refreshTagList) {
|
|
await refreshTags();
|
|
}
|
|
if (showMessage) {
|
|
setStatusMessage('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, refreshTags, setStatusMessage],
|
|
);
|
|
|
|
const handleFolderDelete = useCallback(
|
|
async (folderId?: FolderId, { showMessage = 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;
|
|
}
|
|
|
|
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 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,
|
|
});
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
|
|
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
|
const next = new Map<FolderId, FolderContents>(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<string, any>)?.response?.data?.error || 'Failed to delete folder.';
|
|
notifyApiError(error, message);
|
|
if (showMessage) {
|
|
setStatusMessage(message, 'error');
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
[
|
|
token,
|
|
ensureFolderData,
|
|
selectedFolder,
|
|
folderNodes,
|
|
setSelectedFolder,
|
|
applySelectedFolder,
|
|
setFolderNodes,
|
|
setFolderContents,
|
|
notifyApiError,
|
|
setStatusMessage,
|
|
],
|
|
);
|
|
|
|
return {
|
|
moveDocumentsToFolder,
|
|
handleThumbnailRegeneration,
|
|
handleDocumentsDelete,
|
|
handleDocumentTagAdd,
|
|
handleDocumentTagAttach,
|
|
handleDocumentTitleUpdate,
|
|
handleDocumentIssuedUpdate,
|
|
handleTagRemove,
|
|
handleFolderDelete,
|
|
};
|
|
};
|
|
|
|
export default useDocumentMutations;
|