cleanup bulk actions
This commit is contained in:
@@ -1,185 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||
import { assignCorrespondentsBulk } from '../../lib/api/apiClient';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
import type { MessageOptions } from '../../types/documents';
|
||||
|
||||
type BulkAssignmentResponse = {
|
||||
assigned?: number;
|
||||
removed?: number;
|
||||
};
|
||||
|
||||
type CorrespondentAssignment = {
|
||||
correspondent_id?: Identifier;
|
||||
};
|
||||
|
||||
import type { DocumentsManagerInterface } from '../types/workspaceTypes';
|
||||
|
||||
interface UseBulkDocumentActionsArgs {
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
correspondentLookupByName: Map<string, { id?: Identifier }>;
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>;
|
||||
handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>;
|
||||
clearDocumentSelection: () => void;
|
||||
documentsManager: DocumentsManagerInterface;
|
||||
}
|
||||
|
||||
const useBulkDocumentActions = ({
|
||||
resolveTargetDocumentIds,
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
handleDocumentsDelete,
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
documentsManager,
|
||||
}: UseBulkDocumentActionsArgs) => {
|
||||
const { showToast } = useStatusToast();
|
||||
|
||||
const handleBulkCorrespondentAdd = useCallback(
|
||||
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
showToast('Correspondent name is required.', 'error');
|
||||
return;
|
||||
}
|
||||
const targets = resolveTargetDocumentIds(documentIds);
|
||||
if (!targets.length) {
|
||||
showToast('Select documents before assigning correspondents.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target?.id) {
|
||||
showToast('Unable to resolve correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
|
||||
document_ids: targets,
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: target.id,
|
||||
},
|
||||
],
|
||||
action: 'add',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response;
|
||||
|
||||
if (target.id) {
|
||||
const targetSet = new Set(targets);
|
||||
|
||||
documentsManager.map((doc) => {
|
||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
||||
|
||||
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
|
||||
if (current.includes(target.id)) {
|
||||
return doc;
|
||||
}
|
||||
return {
|
||||
...(doc as any),
|
||||
correspondents: [...current, target.id],
|
||||
};
|
||||
});
|
||||
}
|
||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||
if (removed > 0) {
|
||||
const removedSuffix = removed === 1 ? '' : 's';
|
||||
showToast(
|
||||
`Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`,
|
||||
'success',
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
`Correspondent assigned to ${assigned} document${assignedSuffix}.`,
|
||||
'success',
|
||||
);
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
resolveTargetDocumentIds,
|
||||
showToast,
|
||||
documentsManager,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
|
||||
if (!assignments.length) {
|
||||
showToast('Select a correspondent to remove.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = resolveTargetDocumentIds(documentIds);
|
||||
|
||||
if (!targets.length) {
|
||||
showToast('Select documents before removing correspondents.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedAssignments = assignments.map((entry) => ({
|
||||
correspondent_id: entry.correspondent_id,
|
||||
}));
|
||||
|
||||
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
|
||||
document_ids: targets,
|
||||
assignments: normalizedAssignments,
|
||||
action: 'remove',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response;
|
||||
|
||||
const targetSet = new Set(targets);
|
||||
documentsManager.map((doc) => {
|
||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
||||
|
||||
if (!doc || !Array.isArray((doc as any).correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
const filtered = (doc as any).correspondents.filter(
|
||||
(id: Identifier) =>
|
||||
!normalizedAssignments.some((assignment) => assignment.correspondent_id === id),
|
||||
);
|
||||
return filtered.length === (doc as any).correspondents.length
|
||||
? doc
|
||||
: { ...(doc as any), correspondents: filtered };
|
||||
});
|
||||
|
||||
if (removed > 0) {
|
||||
const removedSuffix = removed === 1 ? '' : 's';
|
||||
showToast(
|
||||
`Correspondent removed from ${removed} link${removedSuffix}.`,
|
||||
'success',
|
||||
);
|
||||
} else if (assigned > 0) {
|
||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||
showToast(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
|
||||
} else {
|
||||
showToast('No correspondents changed.', 'info');
|
||||
}
|
||||
},
|
||||
[resolveTargetDocumentIds, showToast, documentsManager],
|
||||
);
|
||||
|
||||
/*
|
||||
* Bulk Deletion Logic (Handles both Documents and Folders)
|
||||
* Moved other bulk actions to useDocumentMutations to resolve circular dependencies.
|
||||
*/
|
||||
const handleDeleteSelection = useCallback(async () => {
|
||||
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
|
||||
@@ -246,8 +91,6 @@ const useBulkDocumentActions = ({
|
||||
]);
|
||||
|
||||
return {
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleDeleteSelection,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,8 +6,12 @@ import {
|
||||
queueDocumentReanalysis,
|
||||
trashDocument,
|
||||
updateDocument,
|
||||
createTag,
|
||||
bulkTagDocuments,
|
||||
bulkReanalyzeDocuments,
|
||||
assignCorrespondentsBulk,
|
||||
} from '../../lib/api/apiClient';
|
||||
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers';
|
||||
import type { DocumentId, FolderId as FolderIdentifier, Identifier } from '../../types/identifiers';
|
||||
import type { Document, MessageOptions } from '../../types/documents';
|
||||
import { useDocumentTagMutations } from './useDocumentTagMutations';
|
||||
import { useDocumentMoveMutations } from './useDocumentMoveMutations';
|
||||
@@ -29,6 +33,24 @@ interface DocumentTagExtras {
|
||||
input?: { value?: string } | null;
|
||||
}
|
||||
|
||||
interface BulkTagOperationArgs {
|
||||
labels: string[];
|
||||
action: 'add' | 'remove';
|
||||
documentIds?: Identifier[];
|
||||
}
|
||||
|
||||
interface BulkTagOperationResult {
|
||||
ok: boolean;
|
||||
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
|
||||
label?: string;
|
||||
tagCount?: number;
|
||||
docsCount?: number;
|
||||
}
|
||||
|
||||
type CorrespondentAssignment = {
|
||||
correspondent_id?: Identifier;
|
||||
};
|
||||
|
||||
interface UseDocumentMutationsArgs {
|
||||
documentsState: DocumentsState;
|
||||
folderState: FolderState;
|
||||
@@ -37,6 +59,7 @@ interface UseDocumentMutationsArgs {
|
||||
correspondentsState: CorrespondentsState;
|
||||
closeDocumentPreview: () => void;
|
||||
previewDocumentId?: DocumentId | null;
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsResult {
|
||||
@@ -67,6 +90,11 @@ interface UseDocumentMutationsResult {
|
||||
handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial<Correspondent> | null }) => Promise<boolean>;
|
||||
handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => Promise<boolean>;
|
||||
handleDocumentCorrespondentAdd: (args: { document: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial<Correspondent> | string | null }) => Promise<void>;
|
||||
handleBulkCorrespondentAdd: (args: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkCorrespondentRemove: (args: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkTagAddFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkTagRemoveFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>;
|
||||
handleBulkSelectionReanalyze: (documentIdsOverride?: Identifier[] | null) => Promise<void>;
|
||||
}
|
||||
|
||||
const useDocumentMutations = ({
|
||||
@@ -77,6 +105,7 @@ const useDocumentMutations = ({
|
||||
correspondentsState,
|
||||
closeDocumentPreview,
|
||||
previewDocumentId,
|
||||
resolveTargetDocumentIds,
|
||||
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
@@ -225,6 +254,206 @@ const useDocumentMutations = ({
|
||||
],
|
||||
);
|
||||
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
|
||||
if (!labels?.length) return { ok: false, reason: 'no-labels' };
|
||||
|
||||
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetIds?.length) return { ok: false, reason: 'no-selection' };
|
||||
|
||||
const existingTags = tagsState.tags || [];
|
||||
const tagMap = new Map(existingTags.map((t) => [t.label, t]));
|
||||
|
||||
const tagsToProcess: Tag[] = [];
|
||||
const labelsToCreate: string[] = [];
|
||||
|
||||
for (const lbl of labels) {
|
||||
const tag = tagMap.get(lbl);
|
||||
if (tag) {
|
||||
tagsToProcess.push(tag);
|
||||
} else if (action === 'add') {
|
||||
labelsToCreate.push(lbl);
|
||||
}
|
||||
}
|
||||
|
||||
for (const lbl of labelsToCreate) {
|
||||
try {
|
||||
// Use API directly to create tag
|
||||
const created = await createTag({ label: lbl, color: '#c0c0c0' });
|
||||
if (created) {
|
||||
tagsToProcess.push(created as Tag);
|
||||
if (tagsState.tagManager && typeof tagsState.tagManager.ingest === 'function') {
|
||||
tagsState.tagManager.ingest([created as Tag]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create tag', lbl, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tagsToProcess.length && action === 'add') {
|
||||
return { ok: false, reason: 'tag-missing' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tagIds = tagsToProcess.map(t => t.id);
|
||||
await bulkTagDocuments({ document_ids: targetIds, tag_ids: tagIds, action });
|
||||
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (!targetIds.includes(doc.id)) return undefined;
|
||||
const oldTags = doc.tags || [];
|
||||
let newTags = [...oldTags];
|
||||
const processIds = new Set(tagIds);
|
||||
|
||||
if (action === 'add') {
|
||||
const currentIds = new Set(oldTags);
|
||||
tagIds.forEach(tid => {
|
||||
if (!currentIds.has(tid)) newTags.push(tid);
|
||||
});
|
||||
} else {
|
||||
newTags = newTags.filter(tid => !processIds.has(tid));
|
||||
}
|
||||
return { ...doc, tags: newTags };
|
||||
});
|
||||
|
||||
return { ok: true, docsCount: targetIds.length, tagCount: tagsToProcess.length, label: labels[0] };
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Bulk tag operation failed');
|
||||
return { ok: false, reason: 'request-failed' };
|
||||
}
|
||||
},
|
||||
[documentsState, resolveTargetDocumentIds, tagsState, notifyApiError]
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const text = label || input?.value?.trim();
|
||||
if (!text) return;
|
||||
|
||||
const res = await bulkTagOperation({ labels: [text], action: 'add', documentIds });
|
||||
if (res.ok) {
|
||||
showToast(`Added tag "${text}" to ${res.docsCount} documents.`, 'success');
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}, [bulkTagOperation, showToast]);
|
||||
|
||||
const handleBulkTagRemoveFromDetail = useCallback(async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const text = label || input?.value?.trim();
|
||||
if (!text) return;
|
||||
|
||||
const res = await bulkTagOperation({ labels: [text], action: 'remove', documentIds });
|
||||
if (res.ok) {
|
||||
showToast(`Removed tag "${text}" from ${res.docsCount} documents.`, 'success');
|
||||
}
|
||||
}, [bulkTagOperation, showToast]);
|
||||
|
||||
const handleBulkSelectionReanalyze = useCallback(async (documentIdsOverride?: Identifier[] | null) => {
|
||||
const ids = resolveTargetDocumentIds(documentIdsOverride || undefined);
|
||||
if (!ids.length) {
|
||||
showToast('No documents selected.', 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bulkReanalyzeDocuments({ document_ids: ids });
|
||||
showToast(`Queued reanalysis for ${ids.length} documents.`, 'success');
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Failed to queue reanalysis');
|
||||
}
|
||||
}, [resolveTargetDocumentIds, showToast, notifyApiError]);
|
||||
|
||||
const handleBulkCorrespondentAdd = useCallback(async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const text = name || input?.value?.trim();
|
||||
if (!text) return;
|
||||
const ids = resolveTargetDocumentIds(documentIds);
|
||||
if (!ids.length) return;
|
||||
|
||||
const { correspondentManager, correspondentLookupByName } = correspondentsState;
|
||||
const normalized = text.trim();
|
||||
|
||||
let corr = correspondentLookupByName?.get(normalized.toLowerCase());
|
||||
|
||||
if (!corr) {
|
||||
try {
|
||||
// Create new correspondent
|
||||
const payload = correspondentManager.buildPayload({ name: normalized });
|
||||
corr = await correspondentManager.create(payload);
|
||||
} catch (e) {
|
||||
console.error('Failed to create correspondent', e);
|
||||
showToast('Failed to create correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!corr) {
|
||||
showToast('Correspondent could not be found or created.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await assignCorrespondentsBulk({
|
||||
document_ids: ids,
|
||||
assignments: [{ correspondent_id: corr.id }],
|
||||
action: 'add'
|
||||
});
|
||||
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (ids.includes(doc.id)) {
|
||||
const current = doc.correspondents || [];
|
||||
if (corr?.id && !current.includes(corr.id)) {
|
||||
return { ...doc, correspondents: [...current, corr.id] };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
showToast(`Assigned "${corr.name}" to ${ids.length} documents.`, 'success');
|
||||
if (input) input.value = '';
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Failed to assign correspondent');
|
||||
}
|
||||
}, [documentsState, correspondentsState, resolveTargetDocumentIds, showToast, notifyApiError]);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(async ({ documentIds }: { documentIds?: Identifier[] }) => {
|
||||
const ids = resolveTargetDocumentIds(documentIds);
|
||||
if (!ids.length) return;
|
||||
|
||||
const correspondentsToRemove = new Set<Identifier>();
|
||||
ids.forEach(docId => {
|
||||
const doc = documentsState.documentLookup.get(docId);
|
||||
if (doc?.correspondents?.length) {
|
||||
doc.correspondents.forEach(cId => correspondentsToRemove.add(cId));
|
||||
}
|
||||
});
|
||||
|
||||
if (correspondentsToRemove.size === 0) {
|
||||
showToast('No correspondents found to remove.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const assignments = Array.from(correspondentsToRemove).map(id => ({ correspondent_id: id }));
|
||||
|
||||
try {
|
||||
await assignCorrespondentsBulk({
|
||||
document_ids: ids,
|
||||
assignments,
|
||||
action: 'remove'
|
||||
});
|
||||
|
||||
documentsState.documentsManager.map((doc) => {
|
||||
if (ids.includes(doc.id)) {
|
||||
// Remove any of the targeted correspondents from the document
|
||||
const current = doc.correspondents || [];
|
||||
const newCorrespondents = current.filter(cId => !correspondentsToRemove.has(cId));
|
||||
if (current.length !== newCorrespondents.length) {
|
||||
return { ...doc, correspondents: newCorrespondents };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
showToast(`Removed correspondents from ${ids.length} documents.`, 'success');
|
||||
} catch (e) {
|
||||
notifyApiError(e, 'Failed to remove correspondents');
|
||||
}
|
||||
}, [documentsState, resolveTargetDocumentIds, showToast, notifyApiError]);
|
||||
|
||||
return {
|
||||
moveDocumentsToFolder,
|
||||
handleThumbnailRegeneration,
|
||||
@@ -237,6 +466,11 @@ const useDocumentMutations = ({
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleDocumentCorrespondentDetach,
|
||||
handleDocumentCorrespondentAdd,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import FoldersManager from '../FoldersManager';
|
||||
import { fetchDocument } from '../../lib/api/apiClient';
|
||||
import useFolderTree from '../features/folders/useFolderTree';
|
||||
import useFolderTreeActions from '../features/folders/useFolderTreeActions';
|
||||
import useDocumentTagActions from '../features/tagging/useDocumentTagActions';
|
||||
import useDocumentUploads from '../features/upload/useDocumentUploads';
|
||||
import useDocumentDragHandlers from '../features/upload/useDocumentDragHandlers';
|
||||
import useDocumentMutations from './useDocumentMutations';
|
||||
@@ -535,18 +534,6 @@ const useDocumentsWorkspace = ({
|
||||
}
|
||||
}, [refreshFolderData, showToast]);
|
||||
|
||||
const {
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
} = useDocumentTagActions({
|
||||
tags: tagsState.tags,
|
||||
tagManager,
|
||||
refreshTags: tagsState.refreshTags,
|
||||
resolveTargetDocumentIds,
|
||||
documentsManager,
|
||||
});
|
||||
|
||||
const upload = useDocumentUploads({
|
||||
selectedFolder,
|
||||
currentFolderName,
|
||||
@@ -646,6 +633,7 @@ const useDocumentsWorkspace = ({
|
||||
},
|
||||
closeDocumentPreview,
|
||||
previewDocumentId,
|
||||
resolveTargetDocumentIds,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -659,14 +647,15 @@ const useDocumentsWorkspace = ({
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleDocumentCorrespondentDetach,
|
||||
handleDocumentCorrespondentAdd,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
} = documentMutationsResult;
|
||||
|
||||
const mutations = {
|
||||
...documentMutationsResult,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
draggedDocumentIds,
|
||||
};
|
||||
// Wait, mutations object is line 663. handleDeleteSelection is defined later (line 787).
|
||||
// This ordering is problematic if mutations is used before.
|
||||
|
||||
const dragState = {
|
||||
draggedDocumentIds,
|
||||
@@ -786,21 +775,23 @@ const useDocumentsWorkspace = ({
|
||||
}, [appStatus, appDispatch, initializeAfterLogin]);
|
||||
|
||||
const {
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleDeleteSelection,
|
||||
} = useBulkDocumentActions({
|
||||
resolveTargetDocumentIds,
|
||||
correspondentLookupByName: correspondentsState.correspondentLookupByName,
|
||||
handleCorrespondentCreate: correspondentsState.handleCorrespondentCreate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
handleDocumentsDelete,
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection: selectionContext.clearDocumentSelection,
|
||||
documentsManager,
|
||||
});
|
||||
|
||||
const mutations = {
|
||||
...documentMutationsResult,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
draggedDocumentIds,
|
||||
handleDeleteSelection,
|
||||
};
|
||||
|
||||
const ensureAssetUrl = useCallback(
|
||||
async (documentId, asset, { force = false } = {}) => {
|
||||
if (!documentId || !asset?.id) {
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStatusToast } from '../../../lib/context/StatusToastContext';
|
||||
|
||||
import type { Identifier } from '../../../types/identifiers';
|
||||
|
||||
import { createTag, bulkTagDocuments, bulkReanalyzeDocuments } from '../../../lib/api/apiClient';
|
||||
|
||||
import useNotifyApiError from '../../../hooks/useNotifyApiError';
|
||||
import type { DocumentsManagerInterface, TagManager } from '../../types/workspaceTypes';
|
||||
import type { Tag } from '../../../types/documents';
|
||||
|
||||
interface UseDocumentTaggingArgs {
|
||||
tags: Tag[];
|
||||
tagManager: TagManager;
|
||||
refreshTags: () => Promise<void> | void;
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
documentsManager: DocumentsManagerInterface;
|
||||
}
|
||||
|
||||
interface BulkTagOperationArgs {
|
||||
labels: string[];
|
||||
action: 'add' | 'remove';
|
||||
documentIds?: Identifier[];
|
||||
}
|
||||
|
||||
interface BulkTagOperationResult {
|
||||
ok: boolean;
|
||||
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
|
||||
label?: string;
|
||||
tagCount?: number;
|
||||
docsCount?: number;
|
||||
}
|
||||
|
||||
const useDocumentTagActions = ({
|
||||
tags,
|
||||
tagManager,
|
||||
refreshTags,
|
||||
resolveTargetDocumentIds,
|
||||
documentsManager,
|
||||
}: UseDocumentTaggingArgs) => {
|
||||
const { showToast } = useStatusToast();
|
||||
const notifyApiError = useNotifyApiError();
|
||||
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
|
||||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||
if (!normalized.length) {
|
||||
return { ok: false, reason: 'no-labels' };
|
||||
}
|
||||
const targetDocumentIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetDocumentIds.length) {
|
||||
return { ok: false, reason: 'no-selection' };
|
||||
}
|
||||
|
||||
let tagIds: Identifier[] = [];
|
||||
|
||||
if (action === 'remove') {
|
||||
const missing = normalized.find(
|
||||
(label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()),
|
||||
);
|
||||
if (missing) {
|
||||
return { ok: false, reason: 'tag-missing', label: missing };
|
||||
}
|
||||
|
||||
tagIds = normalized.map((label) => {
|
||||
const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase());
|
||||
return tag?.id;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'add') {
|
||||
const createdIds: Identifier[] = [];
|
||||
const createdTags: Tag[] = [];
|
||||
for (const label of normalized) {
|
||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label }) as { label: string; color?: string | null };
|
||||
const response = await createTag(payload);
|
||||
const newTagRaw = response as any;
|
||||
if (!newTagRaw.id) throw new Error('Created tag missing ID');
|
||||
|
||||
tag = {
|
||||
id: newTagRaw.id,
|
||||
label: newTagRaw.label,
|
||||
color: newTagRaw.color
|
||||
} as Tag;
|
||||
|
||||
await refreshTags();
|
||||
}
|
||||
createdIds.push(tag.id);
|
||||
createdTags.push(tag);
|
||||
}
|
||||
tagIds = Array.from(new Set(createdIds));
|
||||
|
||||
const tagById = new Map<Identifier, Tag>();
|
||||
tags.forEach((tag) => {
|
||||
if (tag?.id != null) {
|
||||
tagById.set(tag.id, tag);
|
||||
}
|
||||
});
|
||||
createdTags.forEach((tag) => {
|
||||
if (tag?.id != null) {
|
||||
tagById.set(tag.id, tag);
|
||||
}
|
||||
});
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
const targetSet = new Set(targetDocumentIds);
|
||||
documentsManager.map((doc) => {
|
||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
||||
|
||||
const currentTags: Identifier[] = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
let nextTags = [...currentTags];
|
||||
let changed = false;
|
||||
|
||||
tagIds.forEach((tagId) => {
|
||||
if (nextTags.includes(tagId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextTags.push(tagId);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
return changed ? { ...doc, tags: nextTags } : doc;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tagIds = Array.from(new Set(tagIds));
|
||||
|
||||
if (!tagIds.length) {
|
||||
return { ok: false, reason: 'no-tags' };
|
||||
}
|
||||
|
||||
await bulkTagDocuments({
|
||||
document_ids: targetDocumentIds,
|
||||
tag_ids: tagIds,
|
||||
action,
|
||||
});
|
||||
|
||||
if (action === 'remove') {
|
||||
const targetSet = new Set(targetDocumentIds);
|
||||
const removeSet = new Set(tagIds);
|
||||
|
||||
documentsManager.map((doc) => {
|
||||
if (!targetSet.has(doc.id as Identifier)) return undefined;
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
const currentTags = doc.tags as Identifier[];
|
||||
const filtered = currentTags.filter((id) => !removeSet.has(id));
|
||||
return filtered.length === currentTags.length ? doc : { ...doc, tags: filtered };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tagCount: tagIds.length,
|
||||
docsCount: targetDocumentIds.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response?.data?.error ||
|
||||
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
|
||||
notifyApiError(error, message);
|
||||
return { ok: false, reason: 'request-failed' };
|
||||
}
|
||||
},
|
||||
[
|
||||
resolveTargetDocumentIds,
|
||||
tags,
|
||||
refreshTags,
|
||||
notifyApiError,
|
||||
tagManager,
|
||||
documentsManager,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = label?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
showToast('Enter a tag label.', 'error');
|
||||
return;
|
||||
}
|
||||
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetIds.length) {
|
||||
showToast('Select documents before assigning tags.', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await bulkTagOperation({
|
||||
labels: [trimmed],
|
||||
action: 'add',
|
||||
documentIds: targetIds,
|
||||
});
|
||||
if (result?.ok) {
|
||||
const { tagCount, docsCount } = result;
|
||||
showToast(
|
||||
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${docsCount === 1 ? '' : 's'
|
||||
}.`,
|
||||
'success',
|
||||
);
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
[bulkTagOperation, resolveTargetDocumentIds, showToast],
|
||||
);
|
||||
|
||||
const handleBulkTagRemoveFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = label?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
showToast('Enter a tag label to remove.', 'error');
|
||||
return;
|
||||
}
|
||||
const targetIds = resolveTargetDocumentIds(documentIds);
|
||||
if (!targetIds.length) {
|
||||
showToast('Select documents before removing tags.', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await bulkTagOperation({
|
||||
labels: [trimmed],
|
||||
action: 'remove',
|
||||
documentIds: targetIds,
|
||||
});
|
||||
if (result?.ok) {
|
||||
const { docsCount } = result;
|
||||
showToast(
|
||||
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
);
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
} else if (result?.reason === 'tag-missing') {
|
||||
showToast(`Tag “${result.label}” not found.`, 'error');
|
||||
}
|
||||
},
|
||||
[bulkTagOperation, resolveTargetDocumentIds, showToast],
|
||||
);
|
||||
|
||||
const handleBulkSelectionReanalyze = useCallback(
|
||||
async (documentIdsOverride: Identifier[] | null = null) => {
|
||||
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
||||
if (!targetIds.length) {
|
||||
showToast('Select documents before requesting re-analysis.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await bulkReanalyzeDocuments({
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
});
|
||||
const payload = response;
|
||||
const queued = payload?.queued != null
|
||||
? Number(payload.queued)
|
||||
: targetIds.length;
|
||||
showToast(
|
||||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
||||
notifyApiError(error, message);
|
||||
}
|
||||
},
|
||||
[resolveTargetDocumentIds, notifyApiError, showToast],
|
||||
);
|
||||
|
||||
return {
|
||||
bulkTagOperation,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentTagActions;
|
||||
@@ -2,7 +2,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import type { DocumentId, FolderId, Identifier } from '../../types/identifiers';
|
||||
import type { Document, FolderNode, Tag, Correspondent } from '../../types/documents';
|
||||
|
||||
export interface TagManager {
|
||||
interface TagManager {
|
||||
normalizeLabel: (label: string) => string;
|
||||
buildPayload: (args: { label: string; color?: string | null }) => Record<string, unknown>;
|
||||
ingest: (tags: Tag[]) => void;
|
||||
@@ -16,7 +16,7 @@ export interface CorrespondentManager {
|
||||
create: (payload: Record<string, unknown>) => Promise<Correspondent>;
|
||||
}
|
||||
|
||||
export interface DocumentsManagerInterface {
|
||||
interface DocumentsManagerInterface {
|
||||
map(mapper: (doc: Document) => Document | undefined): boolean;
|
||||
ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean };
|
||||
remove(ids: Array<DocumentId>): boolean;
|
||||
|
||||
Reference in New Issue
Block a user