cleanup bulk actions

This commit is contained in:
2025-12-10 03:06:14 +01:00
parent b9f0278329
commit d15d5388fa
5 changed files with 257 additions and 473 deletions
@@ -1,185 +1,30 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useStatusToast } from '../../lib/context/StatusToastContext'; import { useStatusToast } from '../../lib/context/StatusToastContext';
import { assignCorrespondentsBulk } from '../../lib/api/apiClient';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
import type { MessageOptions } from '../../types/documents'; 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 { interface UseBulkDocumentActionsArgs {
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
correspondentLookupByName: Map<string, { id?: Identifier }>;
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
selectedDocumentIds?: Identifier[]; selectedDocumentIds?: Identifier[];
selectedFolderIds?: Identifier[]; selectedFolderIds?: Identifier[];
handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>; handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>;
handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>; handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>;
clearDocumentSelection: () => void; clearDocumentSelection: () => void;
documentsManager: DocumentsManagerInterface;
} }
const useBulkDocumentActions = ({ const useBulkDocumentActions = ({
resolveTargetDocumentIds,
correspondentLookupByName,
handleCorrespondentCreate,
selectedDocumentIds, selectedDocumentIds,
selectedFolderIds, selectedFolderIds,
handleDocumentsDelete, handleDocumentsDelete,
handleFolderDelete, handleFolderDelete,
clearDocumentSelection, clearDocumentSelection,
documentsManager,
}: UseBulkDocumentActionsArgs) => { }: UseBulkDocumentActionsArgs) => {
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
const handleBulkCorrespondentAdd = useCallback( /*
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { * Bulk Deletion Logic (Handles both Documents and Folders)
const trimmed = name?.trim?.() || ''; * Moved other bulk actions to useDocumentMutations to resolve circular dependencies.
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],
);
const handleDeleteSelection = useCallback(async () => { const handleDeleteSelection = useCallback(async () => {
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : []; const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
@@ -246,8 +91,6 @@ const useBulkDocumentActions = ({
]); ]);
return { return {
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleDeleteSelection, handleDeleteSelection,
}; };
}; };
@@ -6,8 +6,12 @@ import {
queueDocumentReanalysis, queueDocumentReanalysis,
trashDocument, trashDocument,
updateDocument, updateDocument,
createTag,
bulkTagDocuments,
bulkReanalyzeDocuments,
assignCorrespondentsBulk,
} from '../../lib/api/apiClient'; } 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 type { Document, MessageOptions } from '../../types/documents';
import { useDocumentTagMutations } from './useDocumentTagMutations'; import { useDocumentTagMutations } from './useDocumentTagMutations';
import { useDocumentMoveMutations } from './useDocumentMoveMutations'; import { useDocumentMoveMutations } from './useDocumentMoveMutations';
@@ -29,6 +33,24 @@ interface DocumentTagExtras {
input?: { value?: string } | null; 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 { interface UseDocumentMutationsArgs {
documentsState: DocumentsState; documentsState: DocumentsState;
folderState: FolderState; folderState: FolderState;
@@ -37,6 +59,7 @@ interface UseDocumentMutationsArgs {
correspondentsState: CorrespondentsState; correspondentsState: CorrespondentsState;
closeDocumentPreview: () => void; closeDocumentPreview: () => void;
previewDocumentId?: DocumentId | null; previewDocumentId?: DocumentId | null;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
} }
interface UseDocumentMutationsResult { interface UseDocumentMutationsResult {
@@ -67,6 +90,11 @@ interface UseDocumentMutationsResult {
handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial<Correspondent> | null }) => Promise<boolean>; handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial<Correspondent> | null }) => Promise<boolean>;
handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => 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>; 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 = ({ const useDocumentMutations = ({
@@ -77,6 +105,7 @@ const useDocumentMutations = ({
correspondentsState, correspondentsState,
closeDocumentPreview, closeDocumentPreview,
previewDocumentId, previewDocumentId,
resolveTargetDocumentIds,
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => { }: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError(); 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 { return {
moveDocumentsToFolder, moveDocumentsToFolder,
handleThumbnailRegeneration, handleThumbnailRegeneration,
@@ -237,6 +466,11 @@ const useDocumentMutations = ({
handleDocumentCorrespondentAttach, handleDocumentCorrespondentAttach,
handleDocumentCorrespondentDetach, handleDocumentCorrespondentDetach,
handleDocumentCorrespondentAdd, handleDocumentCorrespondentAdd,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze,
}; };
}; };
@@ -40,7 +40,6 @@ import FoldersManager from '../FoldersManager';
import { fetchDocument } from '../../lib/api/apiClient'; import { fetchDocument } from '../../lib/api/apiClient';
import useFolderTree from '../features/folders/useFolderTree'; import useFolderTree from '../features/folders/useFolderTree';
import useFolderTreeActions from '../features/folders/useFolderTreeActions'; import useFolderTreeActions from '../features/folders/useFolderTreeActions';
import useDocumentTagActions from '../features/tagging/useDocumentTagActions';
import useDocumentUploads from '../features/upload/useDocumentUploads'; import useDocumentUploads from '../features/upload/useDocumentUploads';
import useDocumentDragHandlers from '../features/upload/useDocumentDragHandlers'; import useDocumentDragHandlers from '../features/upload/useDocumentDragHandlers';
import useDocumentMutations from './useDocumentMutations'; import useDocumentMutations from './useDocumentMutations';
@@ -535,18 +534,6 @@ const useDocumentsWorkspace = ({
} }
}, [refreshFolderData, showToast]); }, [refreshFolderData, showToast]);
const {
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze,
} = useDocumentTagActions({
tags: tagsState.tags,
tagManager,
refreshTags: tagsState.refreshTags,
resolveTargetDocumentIds,
documentsManager,
});
const upload = useDocumentUploads({ const upload = useDocumentUploads({
selectedFolder, selectedFolder,
currentFolderName, currentFolderName,
@@ -646,6 +633,7 @@ const useDocumentsWorkspace = ({
}, },
closeDocumentPreview, closeDocumentPreview,
previewDocumentId, previewDocumentId,
resolveTargetDocumentIds,
}); });
const { const {
@@ -659,14 +647,15 @@ const useDocumentsWorkspace = ({
handleDocumentCorrespondentAttach, handleDocumentCorrespondentAttach,
handleDocumentCorrespondentDetach, handleDocumentCorrespondentDetach,
handleDocumentCorrespondentAdd, handleDocumentCorrespondentAdd,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze,
} = documentMutationsResult; } = documentMutationsResult;
const mutations = { // Wait, mutations object is line 663. handleDeleteSelection is defined later (line 787).
...documentMutationsResult, // This ordering is problematic if mutations is used before.
handleDocumentDragStart,
handleDocumentDragEnd,
draggedDocumentIds,
};
const dragState = { const dragState = {
draggedDocumentIds, draggedDocumentIds,
@@ -786,21 +775,23 @@ const useDocumentsWorkspace = ({
}, [appStatus, appDispatch, initializeAfterLogin]); }, [appStatus, appDispatch, initializeAfterLogin]);
const { const {
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleDeleteSelection, handleDeleteSelection,
} = useBulkDocumentActions({ } = useBulkDocumentActions({
resolveTargetDocumentIds,
correspondentLookupByName: correspondentsState.correspondentLookupByName,
handleCorrespondentCreate: correspondentsState.handleCorrespondentCreate,
selectedDocumentIds, selectedDocumentIds,
selectedFolderIds, selectedFolderIds,
handleDocumentsDelete, handleDocumentsDelete,
handleFolderDelete, handleFolderDelete,
clearDocumentSelection: selectionContext.clearDocumentSelection, clearDocumentSelection: selectionContext.clearDocumentSelection,
documentsManager,
}); });
const mutations = {
...documentMutationsResult,
handleDocumentDragStart,
handleDocumentDragEnd,
draggedDocumentIds,
handleDeleteSelection,
};
const ensureAssetUrl = useCallback( const ensureAssetUrl = useCallback(
async (documentId, asset, { force = false } = {}) => { async (documentId, asset, { force = false } = {}) => {
if (!documentId || !asset?.id) { 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 { DocumentId, FolderId, Identifier } from '../../types/identifiers';
import type { Document, FolderNode, Tag, Correspondent } from '../../types/documents'; import type { Document, FolderNode, Tag, Correspondent } from '../../types/documents';
export interface TagManager { interface TagManager {
normalizeLabel: (label: string) => string; normalizeLabel: (label: string) => string;
buildPayload: (args: { label: string; color?: string | null }) => Record<string, unknown>; buildPayload: (args: { label: string; color?: string | null }) => Record<string, unknown>;
ingest: (tags: Tag[]) => void; ingest: (tags: Tag[]) => void;
@@ -16,7 +16,7 @@ export interface CorrespondentManager {
create: (payload: Record<string, unknown>) => Promise<Correspondent>; create: (payload: Record<string, unknown>) => Promise<Correspondent>;
} }
export interface DocumentsManagerInterface { interface DocumentsManagerInterface {
map(mapper: (doc: Document) => Document | undefined): boolean; map(mapper: (doc: Document) => Document | undefined): boolean;
ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean }; ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean };
remove(ids: Array<DocumentId>): boolean; remove(ids: Array<DocumentId>): boolean;