diff --git a/frontend/src/documents/data/useBulkDocumentActions.ts b/frontend/src/documents/data/useBulkDocumentActions.ts index c054400..06b050e 100644 --- a/frontend/src/documents/data/useBulkDocumentActions.ts +++ b/frontend/src/documents/data/useBulkDocumentActions.ts @@ -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; - handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>; selectedDocumentIds?: Identifier[]; selectedFolderIds?: Identifier[]; handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise; handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise; 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({ - 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({ - 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, }; }; diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts index e0b5d07..2038cc5 100644 --- a/frontend/src/documents/data/useDocumentMutations.ts +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -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 | null }) => Promise; handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => Promise; handleDocumentCorrespondentAdd: (args: { document: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial | string | null }) => Promise; + handleBulkCorrespondentAdd: (args: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise; + handleBulkCorrespondentRemove: (args: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => Promise; + handleBulkTagAddFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise; + handleBulkTagRemoveFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise; + handleBulkSelectionReanalyze: (documentIdsOverride?: Identifier[] | null) => Promise; } 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 => { + 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(); + 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, }; }; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index 340c65a..9095387 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -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) { diff --git a/frontend/src/documents/features/tagging/useDocumentTagActions.ts b/frontend/src/documents/features/tagging/useDocumentTagActions.ts deleted file mode 100644 index c268d5a..0000000 --- a/frontend/src/documents/features/tagging/useDocumentTagActions.ts +++ /dev/null @@ -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; - 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 => { - 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(); - 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; diff --git a/frontend/src/documents/types/workspaceTypes.ts b/frontend/src/documents/types/workspaceTypes.ts index 4000768..4aba136 100644 --- a/frontend/src/documents/types/workspaceTypes.ts +++ b/frontend/src/documents/types/workspaceTypes.ts @@ -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; ingest: (tags: Tag[]) => void; @@ -16,7 +16,7 @@ export interface CorrespondentManager { create: (payload: Record) => Promise; } -export interface DocumentsManagerInterface { +interface DocumentsManagerInterface { map(mapper: (doc: Document) => Document | undefined): boolean; ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean }; remove(ids: Array): boolean;