diff --git a/specifyweb/backend/attachment_gw/dataset_views.py b/specifyweb/backend/attachment_gw/dataset_views.py index e3d77983fed..ab69d4ef71d 100644 --- a/specifyweb/backend/attachment_gw/dataset_views.py +++ b/specifyweb/backend/attachment_gw/dataset_views.py @@ -12,6 +12,17 @@ class AttachmentDataSetPT(PermissionTarget): upload = PermissionTargetAction() rollback = PermissionTargetAction() +VALID_MATCHING_MODES = {'mappingFile'} + +def _get_validated_matchingmode(data, existing=None): + """Return a validated matchingmode value or raise ValueError.""" + if 'matchingmode' not in data: + return existing + value = data['matchingmode'] + if value is not None and value not in VALID_MATCHING_MODES: + raise ValueError(f"Invalid matchingmode: {value!r}") + return value + def datasets_view(request): if request.method == 'GET': return http.JsonResponse(Spattachmentdataset.get_meta_fields(request), safe=False) @@ -28,6 +39,7 @@ def datasets_view(request): createdbyagent=request.specify_user_agent, modifiedbyagent=request.specify_user_agent, uploaderstatus="main", + matchingmode=_get_validated_matchingmode(data), # A bit more flexible than workbench. Handles creating datasets with an uploadplan from the start. uploadplan=json.dumps(data['uploadplan']) if 'uploadplan' in data else None ) @@ -49,6 +61,7 @@ def dataset_view(request, ds: Spattachmentdataset): ds.name = attrs.get('name', ds.name) ds.remarks = attrs.get('remarks', ds.remarks) ds.data = attrs.get('rows', ds.data) + ds.matchingmode = _get_validated_matchingmode(attrs, ds.matchingmode) ds.uploadplan = json.dumps(attrs['uploadplan'] if 'uploadplan' in attrs else ds.uploadplan) # Never preserve uploaderstatus. Making it required for all requests. old_status = ds.uploaderstatus diff --git a/specifyweb/backend/attachment_gw/migrations/0002_add_matchingmode.py b/specifyweb/backend/attachment_gw/migrations/0002_add_matchingmode.py new file mode 100644 index 00000000000..000cee0c578 --- /dev/null +++ b/specifyweb/backend/attachment_gw/migrations/0002_add_matchingmode.py @@ -0,0 +1,18 @@ +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachment_gw', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='spattachmentdataset', + name='matchingmode', + field=models.CharField(max_length=32, null=True, default=None), + ), + ] diff --git a/specifyweb/backend/attachment_gw/models.py b/specifyweb/backend/attachment_gw/models.py index 39834301034..3898b1f52c2 100644 --- a/specifyweb/backend/attachment_gw/models.py +++ b/specifyweb/backend/attachment_gw/models.py @@ -8,6 +8,15 @@ class Spattachmentdataset(Dataset): id = models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID') + matchingmode = models.CharField( + max_length=32, null=True, default=None + ) + + object_response_fields = [ + *Dataset.object_response_fields, + 'matchingmode', + ] + class Meta: db_table = 'attachmentdataset' diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx index fda1cfda79d..cb774fb52e2 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx @@ -208,6 +208,11 @@ export function AttachmentsImportOverlay(): JSX.Element | null { /> {attachmentDataSet.name} + {attachmentDataSet.matchingmode === 'mappingFile' && ( +
+ {attachmentsText.matchByMappingFile()} +
+ )} diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx index 7bd0afe0fbd..f09b144235f 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx @@ -11,7 +11,7 @@ import { userText } from '../../localization/user'; import { wbText } from '../../localization/workbench'; import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; -import type { RA, WritableArray } from '../../utils/types'; +import type { RA } from '../../utils/types'; import type { IR } from '../../utils/types'; import { removeKey, sortFunction } from '../../utils/utils'; import { Container } from '../Atoms'; @@ -30,19 +30,25 @@ import { SelectUploadPath } from './SelectUploadPath'; import type { AttachmentDataSet, FetchedDataSet, + MappingFileColumns, + MappingFileRow, + MatchingMode, PartialUploadableFileSpec, UnBoundFile, } from './types'; import { AttachmentUpload } from './Upload'; import { useEagerDataSet } from './useEagerDataset'; import { + crossReferenceMappingFiles, matchSelectedFiles, + prepareMappingFileSelection, reconstructDeletingAttachment, reconstructUploadingAttachmentSpec, resolveFileNames, } from './utils'; import { AttachmentsValidationDialog } from './ValidationDialog'; import { ViewAttachmentFiles } from './ViewAttachmentFiles'; +import { MatchingModeDialog } from './MatchingModeDialog'; export type AttachmentUploadSpec = { readonly staticPathKey: keyof typeof staticAttachmentImportPaths; @@ -52,6 +58,10 @@ export type AttachmentUploadSpec = { }; export type PartialAttachmentUploadSpec = { readonly fieldFormatter?: UiFormatter; + readonly formatQueryResults?: AttachmentUploadSpec['formatQueryResults']; + readonly matchingMode?: MatchingMode | null; + readonly mappingFileColumns?: MappingFileColumns; + readonly mappingFileData?: RA; } & (AttachmentUploadSpec | { readonly staticPathKey: undefined }); export type EagerDataSet = Omit & { @@ -132,34 +142,97 @@ function AttachmentsImport({ ); const applyFileNames = React.useCallback( - (file: UnBoundFile): PartialUploadableFileSpec => - eagerDataSet.uploadplan.staticPathKey === undefined - ? { uploadFile: file } - : { - uploadFile: { - ...file, - parsedName: resolveFileNames( - file.file.name, - eagerDataSet.uploadplan.formatQueryResults, - eagerDataSet.uploadplan.fieldFormatter - ), - }, + (file: UnBoundFile): PartialUploadableFileSpec => { + // In mapping-file mode, look up the match value from the CSV data + // (works even before a field path is selected) + if ( + eagerDataSet.uploadplan.matchingMode === 'mappingFile' && + eagerDataSet.uploadplan.mappingFileData !== undefined + ) { + const mappingRow = eagerDataSet.uploadplan.mappingFileData.find( + (row) => row.fileName === file.file.name + ); + return { + uploadFile: { + ...file, + parsedName: mappingRow?.matchValue, + mappingMatchValue: mappingRow?.matchValue, + mappingFileName: mappingRow?.fileName, }, - [eagerDataSet.uploadplan.staticPathKey] + }; + } + + if (eagerDataSet.uploadplan.staticPathKey === undefined) + return { uploadFile: file }; + + // Default: filename pattern matching + return { + uploadFile: { + ...file, + parsedName: resolveFileNames( + file.file.name, + eagerDataSet.uploadplan.formatQueryResults, + eagerDataSet.uploadplan.fieldFormatter + ), + }, + }; + }, + [ + eagerDataSet.uploadplan.staticPathKey, + eagerDataSet.uploadplan.matchingMode, + eagerDataSet.uploadplan.mappingFileData, + eagerDataSet.uploadplan.formatQueryResults, + eagerDataSet.uploadplan.fieldFormatter, + ] + ); + + // Single source of truth: dataset-level matchingmode (DB field) or uploadplan-level + const isMappingMode = React.useMemo( + () => + (eagerDataSet.matchingmode ?? eagerDataSet.uploadplan.matchingMode) === + 'mappingFile', + [eagerDataSet.matchingmode, eagerDataSet.uploadplan.matchingMode] ); - const previousKeyRef = React.useRef( + const previousPathKeyRef = React.useRef( attachmentDataSetResource.uploadplan.staticPathKey ); React.useEffect(() => { - // Reset all parsed names if matching path is changed - if (previousKeyRef.current !== eagerDataSet.uploadplan.staticPathKey) { - previousKeyRef.current = eagerDataSet.uploadplan.staticPathKey; + if ( + previousPathKeyRef.current !== eagerDataSet.uploadplan.staticPathKey + ) { + previousPathKeyRef.current = eagerDataSet.uploadplan.staticPathKey; commitFileChange((files) => - files.map(({ uploadFile }) => applyFileNames(uploadFile)) + files.map(({ uploadFile, ...rest }) => ({ + ...rest, + ...applyFileNames(uploadFile), + })) + ); + } + }, [applyFileNames, commitFileChange, eagerDataSet.uploadplan.staticPathKey]); + + // In mapping mode, seed the table with CSV rows so the user sees what to upload + React.useEffect(() => { + if ( + isMappingMode && + eagerDataSet.uploadplan.mappingFileData !== undefined && + eagerDataSet.uploadplan.mappingFileData.length > 0 && + eagerDataSet.rows.length === 0 + ) { + commitFileChange((oldRows) => + crossReferenceMappingFiles( + oldRows, + eagerDataSet.uploadplan.mappingFileData! + ) ); } - }, [applyFileNames, commitFileChange]); + }, [ + eagerDataSet.uploadplan.matchingMode, + eagerDataSet.uploadplan.mappingFileData, + eagerDataSet.rows.length, + commitFileChange, + isMappingMode, + ]); const currentBaseTable = eagerDataSet.uploadplan.staticPathKey === undefined @@ -177,18 +250,35 @@ function AttachmentsImport({ const handleFilesSelected = (files: FileList) => { const filesList = Array.from(files, (file) => applyFileNames({ file })); - const oldRows = eagerDataSet.rows; - const { resolvedFiles, duplicateFiles } = matchSelectedFiles( - oldRows, - filesList - ); - (resolvedFiles as WritableArray).sort( - sortFunction((file) => file.uploadFile.file.name) - ); + const { resolvedFiles, duplicateFiles } = + isMappingMode && eagerDataSet.uploadplan.mappingFileData !== undefined + ? prepareMappingFileSelection( + eagerDataSet.rows, + filesList, + eagerDataSet.uploadplan.mappingFileData + ) + : matchSelectedFiles(eagerDataSet.rows, filesList); + + // Safety net: guarantee no duplicate filenames in the final list + const bestByName = new Map(); + for (const f of resolvedFiles) { + const name = f.uploadFile.file.name; + const existing = bestByName.get(name); + if ( + existing === undefined || + (!(existing.uploadFile.file instanceof File) && + f.uploadFile.file instanceof File) + ) { + bestByName.set(name, f); + } + } + const deduped = [...bestByName.values()]; + + deduped.sort(sortFunction((file) => file.uploadFile.file.name)); commitChange((oldState) => ({ ...oldState, uploaderstatus: 'main', - rows: resolvedFiles, + rows: deduped, })); setDuplicatedFiles(duplicateFiles); }; @@ -200,33 +290,60 @@ function AttachmentsImport({ >([]); const mainHeaders = React.useMemo(() => { - let headers: IR = { - selectedFileName: commonText.selectedFileName(), - fileSize: attachmentsText.fileSize(), - record: ( -
- {currentBaseTable === undefined ? ( - userText.resource() - ) : ( - <> - - {eagerDataSet.uploadplan.staticPathKey === undefined - ? '' - : strictGetTable(currentBaseTable).strictGetField( - staticAttachmentImportPaths[ - eagerDataSet.uploadplan.staticPathKey - ].path - ).label} - - )} -
- ), - progress: attachmentsText.progress(), - }; + const isMapping = isMappingMode; + const baseHeaders: IR = isMapping + ? { + selectedFileName: commonText.selectedFileName(), + matchValue: attachmentsText.matchValue(), + fileSize: attachmentsText.fileSize(), + record: ( +
+ {currentBaseTable === undefined ? ( + userText.resource() + ) : ( + <> + + {eagerDataSet.uploadplan.staticPathKey === undefined + ? '' + : strictGetTable(currentBaseTable).strictGetField( + staticAttachmentImportPaths[ + eagerDataSet.uploadplan.staticPathKey + ].path + ).label} + + )} +
+ ), + progress: attachmentsText.progress(), + } + : { + selectedFileName: commonText.selectedFileName(), + fileSize: attachmentsText.fileSize(), + record: ( +
+ {currentBaseTable === undefined ? ( + userText.resource() + ) : ( + <> + + {eagerDataSet.uploadplan.staticPathKey === undefined + ? '' + : strictGetTable(currentBaseTable).strictGetField( + staticAttachmentImportPaths[ + eagerDataSet.uploadplan.staticPathKey + ].path + ).label} + + )} +
+ ), + progress: attachmentsText.progress(), + }; + let headers = baseHeaders; if (process.env.NODE_ENV === 'development') headers = { ...headers, attachmentId: attachmentsText.attachmentId() }; return headers; - }, [eagerDataSet.uploadplan.staticPathKey]); + }, [eagerDataSet.uploadplan.staticPathKey, eagerDataSet.uploadplan.matchingMode]); const errorContextData = React.useMemo( () => ({ @@ -238,6 +355,15 @@ function AttachmentsImport({ useErrorContext('bulkAttachmentImport', errorContextData); + const [modeDialogDismissed, setModeDialogDismissed] = React.useState(false); + + const showModeDialog = + !modeDialogDismissed && + (eagerDataSet.matchingmode === undefined || + eagerDataSet.matchingmode === null) && + eagerDataSet.uploadplan.staticPathKey === undefined && + eagerDataSet.rows.length === 0; + return (
@@ -269,7 +395,14 @@ function AttachmentsImport({ : (uploadSpec) => { commitChange((oldState) => ({ ...oldState, - uploadplan: uploadSpec, + uploadplan: { + ...uploadSpec, + matchingMode: oldState.uploadplan.matchingMode, + mappingFileColumns: + oldState.uploadplan.mappingFileColumns, + mappingFileData: + oldState.uploadplan.mappingFileData, + }, })); } } @@ -330,6 +463,7 @@ function AttachmentsImport({ @@ -422,6 +556,7 @@ function AttachmentsImport({ )} + {showModeDialog && ( + { + // Default to filename mode if user closes without choosing + setModeDialogDismissed(true); + commitChange((oldState) => ({ + ...oldState, + matchingmode: null, + uploadplan: { + ...oldState.uploadplan, + matchingMode: null, + }, + })); + }} + onContinue={(mode, columns, data) => { + setModeDialogDismissed(true); + commitChange((oldState) => ({ + ...oldState, + matchingmode: mode, + uploadplan: { + ...oldState.uploadplan, + matchingMode: mode, + mappingFileColumns: columns, + mappingFileData: data, + }, + })); + }} + /> + )} ); } diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx new file mode 100644 index 00000000000..0395c74c960 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import { parse } from 'csv-parse/browser/esm'; + +import { attachmentsText } from '../../localization/attachments'; +import { commonText } from '../../localization/common'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { escapeRegExp } from '../../utils/utils'; +import { Select } from '../Atoms/Form'; +import { FilePicker } from '../Molecules/FilePicker'; +import { strictGetTable } from '../DataModel/tables'; +import { staticAttachmentImportPaths } from './importPaths'; +import type { MappingFileColumns, MappingFileRow } from './types'; + +/** + * Parse a CSV file and return headers + data rows + */ +const parseMappingCsv = async ( + file: File +): Promise<{ headers: RA; rows: RA> }> => { + const text = await file.text(); + return new Promise((resolve, reject) => { + parse( + text, + { + delimiter: [',', '\t', '|'], + relaxColumnCount: true, + skipEmptyLines: true, + trim: true, + }, + (error, data: RA>) => { + if (error) { + reject(error); + return; + } + if (data.length === 0) { + reject(new Error(attachmentsText.csvEmptyFile().toString())); + return; + } + const headers = data[0]; + const rows = data + .slice(1) + .filter((row) => row.some((cell) => cell.trim() !== '')); + resolve({ headers, rows }); + } + ); + }); +}; + +/** + * Build case-insensitive regex matchers for attachment field names. + * Matches against both the raw field name (e.g. "catalogNumber") and + * the schema-localized caption (e.g. "Specimen #", "Catalog Number"). + */ +let _fieldMatchersCache: RA | undefined; +function buildFieldMatchers(): RA { + if (_fieldMatchersCache !== undefined) return _fieldMatchersCache; + const matchers: RegExp[] = []; + const seen = new Set(); + + for (const { baseTable, path } of Object.values( + staticAttachmentImportPaths + )) { + const escaped = escapeRegExp(String(path)); + const key = escaped.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + matchers.push(new RegExp(`^${escaped}$`, 'i')); + } + + // Also match against the schema-localized field caption + try { + const field = strictGetTable(baseTable).strictGetField(path); + const label = String(field.label); + const escapedLabel = escapeRegExp(label); + const labelKey = escapedLabel.toLowerCase(); + if (label !== String(path) && !seen.has(labelKey)) { + seen.add(labelKey); + matchers.push(new RegExp(`^${escapedLabel}$`, 'i')); + } + } catch { + // Schema not yet loaded — skip localized labels + } + } + + _fieldMatchersCache = matchers; + return matchers; +} + +/** + * Attempt to auto-detect which columns are match value and file name. + * Match value columns are detected by comparing CSV headers against + * data model field names and schema-localized field captions. + */ +function autoDetectColumns(headers: RA): { + matchValueIndex: number | undefined; + fileNameIndex: number | undefined; +} { + const fieldMatchers = buildFieldMatchers(); + + const fileNamePatterns = [ + /^attachment\s*name$/i, + /^file\s*name$/i, + /^filename$/i, + /^attachment$/i, + /^file$/i, + ]; + + let matchValueIndex: number | undefined; + let fileNameIndex: number | undefined; + + headers.forEach((header, index) => { + if ( + matchValueIndex === undefined && + fieldMatchers.some((p) => p.test(header)) + ) { + matchValueIndex = index; + } + if ( + fileNameIndex === undefined && + fileNamePatterns.some((p) => p.test(header)) + ) { + fileNameIndex = index; + } + }); + + return { matchValueIndex, fileNameIndex }; +} + +export function MappingFileSetup({ + onColumnsSelected: handleColumnsSelected, + disabled, + initialColumns, + initialData, +}: { + readonly onColumnsSelected: ( + columns: MappingFileColumns, + data: RA + ) => void; + readonly disabled?: boolean; + readonly initialColumns?: MappingFileColumns; + readonly initialData?: RA; +}): JSX.Element { + const [headers, setHeaders] = React.useState | undefined>( + undefined + ); + const [rows, setRows] = React.useState> | undefined>(undefined); + const [matchValueIndex, setMatchValueIndex] = React.useState< + number | undefined + >(initialColumns?.matchValueColumnIndex); + const [fileNameIndex, setFileNameIndex] = React.useState( + initialColumns?.fileNameColumnIndex + ); + const [error, setError] = React.useState(undefined); + + // If initial data is provided (restoring from saved dataset), use it + const isRestored = initialColumns !== undefined && initialData !== undefined; + const [hasRestored] = React.useState(isRestored); + + const previousMappingRef = React.useRef(undefined); + const selectionGenerationRef = React.useRef(0); + const handleFileSelected = React.useCallback(async (selectedFile: File) => { + setError(undefined); + setMatchValueIndex(undefined); + setFileNameIndex(undefined); + previousMappingRef.current = undefined; + selectionGenerationRef.current += 1; + const generation = selectionGenerationRef.current; + try { + const parsed = await parseMappingCsv(selectedFile); + if (generation !== selectionGenerationRef.current) return; + setHeaders(parsed.headers); + setRows(parsed.rows); + + // Auto-detect columns + const detected = autoDetectColumns(parsed.headers); + if (detected.matchValueIndex !== undefined) + setMatchValueIndex(detected.matchValueIndex); + if (detected.fileNameIndex !== undefined) + setFileNameIndex(detected.fileNameIndex); + } catch (err) { + if (generation !== selectionGenerationRef.current) return; + setHeaders(undefined); + setRows(undefined); + setError( + err instanceof Error + ? err.message + : attachmentsText.csvParseError().toString() + ); + } + }, []); + + // Notify parent when columns are selected + React.useEffect(() => { + if ( + matchValueIndex === undefined || + fileNameIndex === undefined || + rows === undefined + ) + return; + + const mappingData: RA = filterArray( + rows.map((row) => { + const matchValue = row[matchValueIndex]?.trim(); + const fileName = row[fileNameIndex]?.trim(); + if (matchValue === undefined || fileName === undefined) + return undefined; + if (matchValue === '' || fileName === '') return undefined; + return { matchValue, fileName }; + }) + ); + + const mappingKey = JSON.stringify({ matchValueIndex, fileNameIndex }); + if (mappingKey !== previousMappingRef.current) { + previousMappingRef.current = mappingKey; + handleColumnsSelected( + { + matchValueColumnIndex: matchValueIndex, + fileNameColumnIndex: fileNameIndex, + }, + mappingData + ); + } + }, [matchValueIndex, fileNameIndex, rows, handleColumnsSelected]); + + return ( +
+ {/* Show restored state */} + {hasRestored && initialData !== undefined ? ( +
+
+ {attachmentsText.mappingFileLoaded({ + count: initialData.length, + })} +
+
+ {attachmentsText.mappingFileColumnsInfo({ + matchColumn: + headers?.[initialColumns?.matchValueColumnIndex ?? 0] ?? + String(initialColumns?.matchValueColumnIndex ?? 0), + fileColumn: + headers?.[initialColumns?.fileNameColumnIndex ?? 0] ?? + String(initialColumns?.fileNameColumnIndex ?? 0), + })} +
+
+ ) : ( + <> +
+ +
+ {error !== undefined && ( +
{error}
+ )} + + )} + + {/* Column selection */} + {headers !== undefined && ( +
+ + + +
+ )} +
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MatchingModeDialog.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MatchingModeDialog.tsx new file mode 100644 index 00000000000..952bce84dc1 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MatchingModeDialog.tsx @@ -0,0 +1,135 @@ +import React from 'react'; + +import { attachmentsText } from '../../localization/attachments'; +import { commonText } from '../../localization/common'; +import type { RA } from '../../utils/types'; +import { icons } from '../Atoms/Icons'; +import { Button } from '../Atoms/Button'; +import { Submit } from '../Atoms/Submit'; +import { Dialog } from '../Molecules/Dialog'; +import { MappingFileSetup } from './MappingFileSetup'; +import type { + MappingFileColumns, + MappingFileRow, + MatchingMode, +} from './types'; + +export function MatchingModeDialog({ + onContinue: handleContinue, + onClose: handleClose, + initialMode, + initialColumns, + initialData, +}: { + readonly onContinue: ( + mode: MatchingMode | null, + columns?: MappingFileColumns, + data?: RA + ) => void; + readonly onClose: () => void; + readonly initialMode?: MatchingMode | null; + readonly initialColumns?: MappingFileColumns; + readonly initialData?: RA; +}): JSX.Element { + const [mode, setMode] = React.useState( + initialMode ?? null + ); + const [mappingColumns, setMappingColumns] = React.useState< + MappingFileColumns | undefined + >(initialColumns); + const [mappingData, setMappingData] = React.useState< + RA | undefined + >(initialData); + + const canContinue = + (mode === null || mode === undefined) || + (mode === 'mappingFile' && + mappingColumns !== undefined && + mappingData !== undefined && + mappingData.length > 0); + + return ( + + {commonText.close()} + + handleContinue(mode, mappingColumns, mappingData) + } + > + {commonText.proceed()} + + + } + header={attachmentsText.matchingMode()} + onClose={handleClose} + > +
+
{attachmentsText.chooseMatchingMode()}
+ + + + +
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx index 276b19add08..070d0ac1ccc 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx @@ -19,6 +19,7 @@ import type { PartialAttachmentUploadSpec } from './Import'; import { ResourceDisambiguationDialog } from './ResourceDisambiguation'; import type { PartialUploadableFileSpec } from './types'; import { + isMappingFilePlaceholder, keyLocalizationMapAttachment, resolveAttachmentRecord, resolveAttachmentStatus, @@ -27,7 +28,8 @@ import { const resolveAttachmentDatasetData = ( uploadableFiles: RA, setDisambiguationIndex: (index: number) => void, - baseTableName: keyof Tables | undefined + baseTableName: keyof Tables | undefined, + isMappingMode: boolean = false ) => uploadableFiles.map( ({ uploadFile, status, matchedId, disambiguated, attachmentId }, index) => { @@ -46,7 +48,8 @@ const resolveAttachmentDatasetData = ( : resolveAttachmentRecord( matchedId, disambiguated, - uploadFile.parsedName + uploadFile.parsedName, + isMappingMode ); const isRuntimeError = @@ -54,12 +57,21 @@ const resolveAttachmentDatasetData = ( typeof status === 'object' && (status.type === 'cancelled' || status.type === 'skipped'); - const statusText = f.maybe(status, resolveAttachmentStatus) ?? ''; - return { + const isPlaceholder = + isMappingMode && + isMappingFilePlaceholder({ uploadFile, status } as PartialUploadableFileSpec); + + const statusText = isPlaceholder + ? attachmentsText.awaitingFile() + : f.maybe(status, resolveAttachmentStatus) ?? ''; + + const baseData = { selectedFileName: [ uploadFile.file.name,
- {uploadFile.file instanceof File ? '' : dialogIcons.warning} + {uploadFile.file instanceof File || isPlaceholder + ? '' + : dialogIcons.warning} {uploadFile.file.name}
, ], @@ -100,6 +112,16 @@ const resolveAttachmentDatasetData = ( isNativeError: resolvedRecord?.type === 'invalid', isRuntimeError, } as const; + + return isMappingMode + ? { + ...baseData, + matchValue: [ + uploadFile.mappingMatchValue ?? '', + {uploadFile.mappingMatchValue ?? ''}, + ] as const, + } + : baseData; } ); @@ -109,6 +131,7 @@ export function ViewAttachmentFiles({ onDisambiguation: handleDisambiguation, onFilesDropped: handleFilesDropped, headers, + isMappingMode = false, }: { readonly uploadableFiles: RA; readonly baseTableName: keyof Tables | undefined; @@ -122,6 +145,7 @@ export function ViewAttachmentFiles({ | undefined; readonly onFilesDropped?: (file: FileList) => void; readonly headers: IR; + readonly isMappingMode?: boolean; }): JSX.Element | null { const [disambiguationIndex, setDisambiguationIndex] = React.useState< number | undefined @@ -132,9 +156,10 @@ export function ViewAttachmentFiles({ resolveAttachmentDatasetData( uploadableFiles, setDisambiguationIndex, - baseTableName + baseTableName, + isMappingMode ), - [uploadableFiles, setDisambiguationIndex, baseTableName] + [uploadableFiles, setDisambiguationIndex, baseTableName, isMappingMode] ); const fileDropDivRef = React.useRef(null); @@ -163,7 +188,9 @@ export function ViewAttachmentFiles({
{uploadableFiles.some( - ({ uploadFile: { file } }) => !(file instanceof File) + (row) => + !(row.uploadFile.file instanceof File) && + !(isMappingMode && isMappingFilePlaceholder(row)) ) && ( <> {dialogIcons.warning} @@ -172,6 +199,12 @@ export function ViewAttachmentFiles({ )}
+ {isMappingMode && + uploadableFiles.some(isMappingFilePlaceholder) && ( +
+ {attachmentsText.mappingAwaitingFiles()} +
+ )} `bg-[color:var(--background)] p-2 print:p-1 ${ diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts index 48584ad4fea..7ab7533b0f4 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts @@ -13,9 +13,13 @@ import { import { syncFieldFormat } from '../../Formatters/fieldFormat'; import type { PartialUploadableFileSpec, UnBoundFile } from '../types'; import { + crossReferenceMappingFiles, inferDeletedAttachments, inferUploadedAttachments, + isMappingFilePlaceholder, matchFileSpec, + matchSelectedFiles, + prepareMappingFileSelection, resolveFileNames, } from '../utils'; @@ -354,3 +358,803 @@ test('reconstruct deleting attachment spec', () => { ]; expect(inferDeletedAttachments(queryResults, files)).toMatchSnapshot(); }); + +describe('crossReferenceMappingFiles', () => { + const CSV: RA<{ readonly matchValue: string; readonly fileName: string }> = [ + { matchValue: '000271806', fileName: '271806.jpg' }, + { matchValue: '000601146', fileName: '601146.jpg' }, + { matchValue: '000601146', fileName: '601146_2.jpg' }, + { matchValue: '000273074', fileName: '273074.jpg' }, + ]; + + const makeReal = (name: string, mv: string) => + ({ + uploadFile: { + file: { name, size: 91, type: 'image/jpeg' }, + parsedName: mv, + mappingMatchValue: mv, + }, + }) as PartialUploadableFileSpec; + + test('initial seeding produces all placeholders', () => { + const result = crossReferenceMappingFiles([], CSV); + expect(result).toHaveLength(4); + expect(result.every((r) => (r.status as any)?.reason === 'fileMissing')).toBe(true); + }); + + test('adding one real file replaces corresponding placeholder', () => { + const seeded = crossReferenceMappingFiles([], CSV); + const result = crossReferenceMappingFiles( + [...seeded, makeReal('601146.jpg', '000601146')], + CSV + ); + expect(result).toHaveLength(4); + expect( + result.find((r) => r.uploadFile.file.name === '601146.jpg') + ?.uploadFile.file.size + ).toBe(91); + expect( + result.filter((r) => (r.status as any)?.reason === 'fileMissing') + ).toHaveLength(3); + }); + + test('adding all files removes all placeholders', () => { + const seeded = crossReferenceMappingFiles([], CSV); + const result = crossReferenceMappingFiles( + [ + ...seeded, + makeReal('271806.jpg', '000271806'), + makeReal('601146.jpg', '000601146'), + makeReal('601146_2.jpg', '000601146'), + makeReal('273074.jpg', '000273074'), + ], + CSV + ); + expect(result).toHaveLength(4); + expect( + result.every( + (r) => !r.status || (r.status as any).reason !== 'fileMissing' + ) + ).toBe(true); + expect(result.every((r) => r.uploadFile.file.size === 91)).toBe(true); + }); + + test('batch processing produces no duplicates', () => { + const seeded = crossReferenceMappingFiles([], CSV); + const batch1 = crossReferenceMappingFiles( + [ + ...seeded, + makeReal('271806.jpg', '000271806'), + makeReal('601146.jpg', '000601146'), + ], + CSV + ); + expect(batch1).toHaveLength(4); + + const batch2 = crossReferenceMappingFiles( + [ + ...batch1, + makeReal('601146_2.jpg', '000601146'), + makeReal('273074.jpg', '000273074'), + ], + CSV + ); + expect(batch2).toHaveLength(4); + expect(batch2.every((r) => r.uploadFile.file.size === 91)).toBe(true); + }); + + test('file not in CSV is flagged', () => { + const seeded = crossReferenceMappingFiles([], CSV); + const result = crossReferenceMappingFiles( + [...seeded, makeReal('unknown.jpg', '?')], + CSV + ); + expect( + (result.find((r) => r.uploadFile.file.name === 'unknown.jpg')!.status as any).reason + ).toBe('notInMappingFile'); + }); + + test('duplicate filenames in CSV produce exactly one row', () => { + const csv2: RA<{ readonly matchValue: string; readonly fileName: string }> = + [ + { matchValue: 'A', fileName: 'dup.jpg' }, + { matchValue: 'B', fileName: 'dup.jpg' }, + ]; + const result = crossReferenceMappingFiles( + [...crossReferenceMappingFiles([], csv2), makeReal('dup.jpg', 'A')], + csv2 + ); + expect(result).toHaveLength(1); + expect(result[0].uploadFile.file.size).toBe(91); + }); +}); + +describe('prepareMappingFileSelection', () => { + const CSV: RA<{ readonly matchValue: string; readonly fileName: string }> = [ + { matchValue: '000271806', fileName: '271806.jpg' }, + { matchValue: '000601146', fileName: '601146.jpg' }, + { matchValue: '000601146', fileName: '601146_2.jpg' }, + ]; + + const makePlaceholder = (name: string, mv: string) => + ({ + uploadFile: { + file: { name, size: 0, type: '' }, + parsedName: mv, + mappingMatchValue: mv, + }, + status: { type: 'cancelled', reason: 'fileMissing' }, + }) as PartialUploadableFileSpec; + + const makeReal = (name: string, mv: string) => + ({ + uploadFile: { + file: { name, size: 91, type: 'image/jpeg' }, + parsedName: mv, + mappingMatchValue: mv, + }, + }) as PartialUploadableFileSpec; + + test('replaces seeded placeholders with matching uploaded files', () => { + const seeded = crossReferenceMappingFiles([], CSV); + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + seeded, + [makeReal('601146.jpg', '000601146')], + CSV + ); + + expect(duplicateFiles).toHaveLength(0); + expect(resolvedFiles).toHaveLength(3); + expect( + resolvedFiles.find((r) => r.uploadFile.file.name === '601146.jpg') + ?.uploadFile.file.size + ).toBe(91); + expect( + resolvedFiles.filter((r) => (r.status as any)?.reason === 'fileMissing') + ).toHaveLength(2); + }); + + test('keeps uploaded files that are already real while preserving missing placeholders', () => { + const seeded = crossReferenceMappingFiles([], CSV); + const { resolvedFiles } = prepareMappingFileSelection( + [ + ...seeded, + makeReal('271806.jpg', '000271806'), + makePlaceholder('601146_2.jpg', '000601146'), + ], + [makeReal('601146.jpg', '000601146')], + CSV + ); + + expect(resolvedFiles).toHaveLength(3); + expect(resolvedFiles.every((r) => r.uploadFile.file.size === 91 || (r.status as any)?.reason === 'fileMissing')).toBe(true); + expect( + resolvedFiles.filter((r) => r.uploadFile.file.name === '601146.jpg') + ).toHaveLength(1); + }); +}); + +describe('end-to-end: seeding then full file selection', () => { + // Simulates the real user flow: CSV uploaded → placeholders seeded → user selects files + const FULL_CSV: RA<{ + readonly matchValue: string; + readonly fileName: string; + }> = [ + { matchValue: '000271806', fileName: '271806.jpg' }, + { matchValue: '000601146', fileName: '601146.jpg' }, + { matchValue: '000601146', fileName: '601146_2.jpg' }, + { matchValue: '000273074', fileName: '273074.jpg' }, + { matchValue: '000687972', fileName: '687972.jpg' }, + { matchValue: '000601108', fileName: '601108.jpg' }, + { matchValue: '000728604', fileName: '728604.jpg' }, + { matchValue: '000466309', fileName: '466309.jpg' }, + { matchValue: '000475938', fileName: '475938.jpg' }, + { matchValue: '000855732', fileName: '855732.jpg' }, + ]; + + const EXTRA_FILES = ['601766.jpg', '601766_2.jpg', '601766_3.jpg']; + + const allFileNames = [...FULL_CSV.map((r) => r.fileName), ...EXTRA_FILES]; + + const makeRealFile = ( + name: string, + mv: string | undefined + ): PartialUploadableFileSpec => + ({ + uploadFile: { + file: { name, size: 91, type: 'image/jpeg' } as File, + parsedName: mv, + mappingMatchValue: mv, + mappingFileName: mv !== undefined ? name : undefined, + }, + }) as PartialUploadableFileSpec; + + const countNames = ( + files: RA + ): Map => { + const m = new Map(); + for (const f of files) + m.set(f.uploadFile.file.name, (m.get(f.uploadFile.file.name) ?? 0) + 1); + return m; + }; + + test('1: seeding then selecting all files — every name once, mappingMatchValue set', () => { + const seeded = crossReferenceMappingFiles([], FULL_CSV); + expect(seeded).toHaveLength(10); + + const filesToResolve = allFileNames.map((name) => { + const csvRow = FULL_CSV.find((r) => r.fileName === name); + return makeRealFile(name, csvRow?.matchValue); + }); + + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + seeded, + filesToResolve, + FULL_CSV + ); + + expect(duplicateFiles).toHaveLength(0); + expect(resolvedFiles).toHaveLength(13); + + const counts = countNames(resolvedFiles); + for (const [, c] of counts) expect(c).toBe(1); + + const csvNames = new Set(FULL_CSV.map((r) => r.fileName)); + for (const r of resolvedFiles) { + if (csvNames.has(r.uploadFile.file.name)) { + expect(r.uploadFile.mappingMatchValue).toBeTruthy(); + expect(r.uploadFile.parsedName).toBeTruthy(); + } else { + expect((r.status as any)?.reason).toBe('notInMappingFile'); + } + } + }); + + test('2: selecting all files on empty rows (no prior seeding)', () => { + const filesToResolve = allFileNames.map((name) => { + const csvRow = FULL_CSV.find((r) => r.fileName === name); + return makeRealFile(name, csvRow?.matchValue); + }); + + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + [], + filesToResolve, + FULL_CSV + ); + + expect(duplicateFiles).toHaveLength(0); + expect(resolvedFiles).toHaveLength(13); + + const counts = countNames(resolvedFiles); + for (const [, c] of counts) expect(c).toBe(1); + }); + + test('3: reload scenario — saved rows without mappingMatchValue + re-select files', () => { + // Old saved rows: plain objects, File-like, NO mappingMatchValue + const savedRows = FULL_CSV.map( + (row) => + ({ + uploadFile: { + file: { name: row.fileName, size: 91, type: 'image/jpeg' }, + parsedName: row.matchValue, + }, + }) as PartialUploadableFileSpec + ); + + const newFiles = FULL_CSV.map((row) => + makeRealFile(row.fileName, row.matchValue) + ); + + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + savedRows, + newFiles, + FULL_CSV + ); + + expect(duplicateFiles).toHaveLength(0); + expect(resolvedFiles).toHaveLength(10); + + for (const r of resolvedFiles) { + expect(r.uploadFile.mappingMatchValue).toBeTruthy(); + } + + const counts = countNames(resolvedFiles); + for (const [, c] of counts) expect(c).toBe(1); + }); + + test('4: crossReferenceMappingFiles with old row (no mappingMatchValue) + new file → mappingMatchValue wins', () => { + const oldRow = { + uploadFile: { + file: { name: '271806.jpg', size: 91, type: 'image/jpeg' }, + parsedName: '000271806', + }, + } as PartialUploadableFileSpec; + + const newFile = makeRealFile('271806.jpg', '000271806'); + + const result = crossReferenceMappingFiles([oldRow, newFile], FULL_CSV); + + const matches = result.filter( + (r) => r.uploadFile.file.name === '271806.jpg' + ); + expect(matches).toHaveLength(1); + expect(matches[0].uploadFile.mappingMatchValue).toBe('000271806'); + }); + + test('5: every output row has mappingMatchValue after cross-reference with mixed data', () => { + const seeded = crossReferenceMappingFiles([], FULL_CSV); + const realFiles = FULL_CSV.map((row) => + makeRealFile(row.fileName, row.matchValue) + ); + + const result = crossReferenceMappingFiles( + [...seeded, ...realFiles], + FULL_CSV + ); + + expect(result).toHaveLength(10); + for (const r of result) { + expect(r.uploadFile.mappingMatchValue).toBeTruthy(); + } + }); +}); + +describe('crossReferenceMappingFiles CSV always wins matchValue', () => { + test('CSV matchValue overrides row with undefined mappingMatchValue', () => { + const row = { + uploadFile: { + file: { name: 'test.jpg', size: 91, type: 'image/jpeg' }, + parsedName: 'oldValue', + }, + } as PartialUploadableFileSpec; + + const csv = [{ fileName: 'test.jpg', matchValue: 'newValue' }]; + const result = crossReferenceMappingFiles([row], csv); + + expect(result).toHaveLength(1); + expect(result[0].uploadFile.mappingMatchValue).toBe('newValue'); + expect(result[0].uploadFile.parsedName).toBe('newValue'); + expect(result[0].uploadFile.mappingFileName).toBe('test.jpg'); + }); + + test('CSV matchValue overrides row with different mappingMatchValue', () => { + const row = { + uploadFile: { + file: { name: 'test.jpg', size: 91, type: 'image/jpeg' }, + parsedName: 'oldValue', + mappingMatchValue: 'oldValue', + mappingFileName: 'old.jpg', + }, + } as PartialUploadableFileSpec; + + const csv = [{ fileName: 'test.jpg', matchValue: 'CORRECT_VALUE' }]; + const result = crossReferenceMappingFiles([row], csv); + + expect(result).toHaveLength(1); + expect(result[0].uploadFile.mappingMatchValue).toBe('CORRECT_VALUE'); + expect(result[0].uploadFile.parsedName).toBe('CORRECT_VALUE'); + expect(result[0].uploadFile.mappingFileName).toBe('test.jpg'); + }); +}); + +// --------------------------------------------------------------------------- +// isMappingFilePlaceholder +// --------------------------------------------------------------------------- +describe('isMappingFilePlaceholder', () => { + test('returns true for fileMissing cancelled status', () => { + expect( + isMappingFilePlaceholder({ + uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } }, + status: { type: 'cancelled', reason: 'fileMissing' }, + } as PartialUploadableFileSpec) + ).toBe(true); + }); + + test('returns false for other cancelled reasons', () => { + expect( + isMappingFilePlaceholder({ + uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } }, + status: { type: 'cancelled', reason: 'noMatch' }, + } as PartialUploadableFileSpec) + ).toBe(false); + }); + + test('returns false for skipped status', () => { + expect( + isMappingFilePlaceholder({ + uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } }, + status: { type: 'skipped', reason: 'fileMissing' }, + } as PartialUploadableFileSpec) + ).toBe(false); + }); + + test('returns false for success status', () => { + expect( + isMappingFilePlaceholder({ + uploadFile: { file: { name: 'a.jpg', size: 0, type: '' } }, + status: { type: 'success', successType: 'uploaded' }, + } as PartialUploadableFileSpec) + ).toBe(false); + }); + + test('returns false when status is undefined', () => { + expect( + isMappingFilePlaceholder({ + uploadFile: { file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } }, + } as PartialUploadableFileSpec) + ).toBe(false); + }); + + test('returns false for matched status', () => { + expect( + isMappingFilePlaceholder({ + uploadFile: { file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } }, + status: { type: 'matched', id: 1 }, + } as PartialUploadableFileSpec) + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// matchSelectedFiles – existing function, now tested with mapping scenarios +// --------------------------------------------------------------------------- +describe('matchSelectedFiles', () => { + const mk = (name: string, size = 91) => + ({ + uploadFile: { + file: { name, size, type: 'image/jpeg' }, + }, + }) as PartialUploadableFileSpec; + + test('adds new files to empty list', () => { + const { resolvedFiles, duplicateFiles } = matchSelectedFiles( + [], + [mk('a.jpg'), mk('b.jpg')] + ); + expect(resolvedFiles).toHaveLength(2); + expect(duplicateFiles).toHaveLength(0); + }); + + test('detects exact duplicate when existing entry has File instance', () => { + const content = new Uint8Array(91); + const file = new File([content], 'a.jpg', { type: 'image/jpeg' }); + const existing = [ + { uploadFile: { file } }, + ] as RA; + const { resolvedFiles, duplicateFiles } = matchSelectedFiles(existing, [ + mk('a.jpg'), + ]); + expect(resolvedFiles).toHaveLength(1); + expect(duplicateFiles).toHaveLength(1); + expect(duplicateFiles[0].uploadFile.file.name).toBe('a.jpg'); + }); + + test('placeholder (0 bytes) does NOT match real file (91 bytes) — both kept', () => { + const placeholder = mk('a.jpg', 0); + const real = mk('a.jpg', 91); + const { resolvedFiles } = matchSelectedFiles([placeholder], [real]); + expect(resolvedFiles).toHaveLength(2); + }); + + test('replaces non-File existing entry with new entry', () => { + // Simulating a serialized row (plain object, not File instance) + const saved = { + uploadFile: { + file: { name: 'a.jpg', size: 91, type: 'image/jpeg' }, + }, + } as PartialUploadableFileSpec; + const real = mk('a.jpg', 91); + const { resolvedFiles, duplicateFiles } = matchSelectedFiles( + [saved], + [real] + ); + expect(resolvedFiles).toHaveLength(1); + expect(duplicateFiles).toHaveLength(0); + // The new entry's uploadFile replaced the old one + expect(resolvedFiles[0].uploadFile).toBe(real.uploadFile); + }); + + test('preserves success status when replacing', () => { + const saved = { + uploadFile: { + file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } as File, + }, + status: { type: 'success', successType: 'uploaded' }, + } as PartialUploadableFileSpec; + const real = mk('a.jpg', 91); + const { resolvedFiles } = matchSelectedFiles([saved], [real]); + expect(resolvedFiles).toHaveLength(1); + expect(resolvedFiles[0].status?.type).toBe('success'); + }); + + test('preserves skipped/alreadyUploaded status when replacing', () => { + const saved = { + uploadFile: { + file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } as File, + }, + status: { type: 'skipped', reason: 'alreadyUploaded' }, + } as PartialUploadableFileSpec; + const real = mk('a.jpg', 91); + const { resolvedFiles } = matchSelectedFiles([saved], [real]); + expect(resolvedFiles).toHaveLength(1); + expect(resolvedFiles[0].status?.type).toBe('skipped'); + }); + + test('takes new cancelled status when replacing non-success entry', () => { + const saved = { + uploadFile: { + file: { name: 'a.jpg', size: 91, type: 'image/jpeg' } as File, + }, + status: { type: 'cancelled', reason: 'noMatch' }, + } as PartialUploadableFileSpec; + const real = { + ...mk('a.jpg', 91), + status: { type: 'cancelled', reason: 'fileMissing' }, + } as PartialUploadableFileSpec; + const { resolvedFiles } = matchSelectedFiles([saved], [real]); + expect(resolvedFiles).toHaveLength(1); + expect((resolvedFiles[0].status as any)?.reason).toBe('fileMissing'); + }); +}); + +// --------------------------------------------------------------------------- +// crossReferenceMappingFiles – additional edge cases +// --------------------------------------------------------------------------- +describe('crossReferenceMappingFiles – edge cases', () => { + const mk = ( + name: string, + size: number, + mv?: string + ): PartialUploadableFileSpec => + ({ + uploadFile: { + file: { name, size, type: size > 0 ? 'image/jpeg' : '' }, + parsedName: mv, + mappingMatchValue: mv, + mappingFileName: mv !== undefined ? name : undefined, + }, + }) as PartialUploadableFileSpec; + + test('empty CSV returns empty result', () => { + expect(crossReferenceMappingFiles([], [])).toHaveLength(0); + }); + + test('empty uploadables with CSV produces all placeholders', () => { + const csv = [ + { fileName: 'a.jpg', matchValue: '1' }, + { fileName: 'b.jpg', matchValue: '2' }, + ]; + const result = crossReferenceMappingFiles([], csv); + expect(result).toHaveLength(2); + expect(result.every((r) => (r.status as any)?.reason === 'fileMissing')).toBe(true); + expect(result[0].uploadFile.mappingMatchValue).toBe('1'); + expect(result[1].uploadFile.mappingMatchValue).toBe('2'); + }); + + test('uploadables not in CSV are flagged notInMappingFile', () => { + const csv = [{ fileName: 'a.jpg', matchValue: '1' }]; + const result = crossReferenceMappingFiles( + [mk('a.jpg', 91, '1'), mk('b.jpg', 91), mk('c.jpg', 91)], + csv + ); + const b = result.find((r) => r.uploadFile.file.name === 'b.jpg'); + const c = result.find((r) => r.uploadFile.file.name === 'c.jpg'); + expect((b?.status as any)?.reason).toBe('notInMappingFile'); + expect((c?.status as any)?.reason).toBe('notInMappingFile'); + // 'a' should be present and matched + expect( + result.find((r) => r.uploadFile.file.name === 'a.jpg')?.status + ).toBeUndefined(); + }); + + test('already-uploaded file (with attachmentId) is preserved through cross-reference', () => { + const csv = [{ fileName: 'a.jpg', matchValue: '1' }]; + const uploaded = { + uploadFile: { + file: { name: 'a.jpg', size: 91, type: 'image/jpeg' }, + parsedName: '1', + mappingMatchValue: '1', + }, + attachmentId: 42, + status: { type: 'success', successType: 'uploaded' }, + } as PartialUploadableFileSpec; + + const result = crossReferenceMappingFiles([uploaded], csv); + expect(result).toHaveLength(1); + expect(result[0].attachmentId).toBe(42); + expect(result[0].status?.type).toBe('success'); + }); + + test('byName prefers real File (size 91) over plain object (size 91)', () => { + const csv = [{ fileName: 'a.jpg', matchValue: '1' }]; + // Plain object first (would be first in byName), then real file + const plainObj = { + uploadFile: { + file: { name: 'a.jpg', size: 91, type: 'image/jpeg' }, + parsedName: '1', + mappingMatchValue: 'wrong', + }, + } as PartialUploadableFileSpec; + const realFile = mk('a.jpg', 91, '1'); + // Give realFile a distinguishable property — it has mappingMatchValue set correctly + const result = crossReferenceMappingFiles([plainObj, realFile], csv); + expect(result).toHaveLength(1); + // mappingMatchValue from CSV overrides regardless — both should be '1' + expect(result[0].uploadFile.mappingMatchValue).toBe('1'); + }); + + test('multiple CSV rows with same filename produce exactly one output row', () => { + const csv = [ + { fileName: 'a.jpg', matchValue: '1' }, + { fileName: 'a.jpg', matchValue: '1' }, + { fileName: 'b.jpg', matchValue: '2' }, + ]; + const result = crossReferenceMappingFiles([], csv); + expect(result).toHaveLength(2); + const a = result.filter((r) => r.uploadFile.file.name === 'a.jpg'); + expect(a).toHaveLength(1); + }); + + test('triple duplicate filename in CSV produces one row', () => { + const csv = [ + { fileName: 'x.jpg', matchValue: 'A' }, + { fileName: 'x.jpg', matchValue: 'A' }, + { fileName: 'x.jpg', matchValue: 'A' }, + ]; + const result = crossReferenceMappingFiles([mk('x.jpg', 91, 'A')], csv); + expect(result).toHaveLength(1); + }); + + test('placeholder entries have correct parsedName and mappingMatchValue from CSV', () => { + const csv = [{ fileName: 'test.jpg', matchValue: 'ABC-123' }]; + const result = crossReferenceMappingFiles([], csv); + expect(result).toHaveLength(1); + expect(result[0].uploadFile.parsedName).toBe('ABC-123'); + expect(result[0].uploadFile.mappingMatchValue).toBe('ABC-123'); + expect(result[0].uploadFile.file.size).toBe(0); + }); + + test('status is cleared when real file replaces placeholder (no error status)', () => { + const csv = [{ fileName: 'a.jpg', matchValue: '1' }]; + const seeded = crossReferenceMappingFiles([], csv); + expect((seeded[0].status as any)?.reason).toBe('fileMissing'); + + const result = crossReferenceMappingFiles( + [...seeded, mk('a.jpg', 91, '1')], + csv + ); + expect(result).toHaveLength(1); + expect(result[0].status).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// prepareMappingFileSelection – additional edge cases +// --------------------------------------------------------------------------- +describe('prepareMappingFileSelection – edge cases', () => { + const CSV = [ + { matchValue: 'A', fileName: 'a.jpg' }, + { matchValue: 'B', fileName: 'b.jpg' }, + ]; + + const mk = (name: string, size: number, mv?: string) => + ({ + uploadFile: { + file: { name, size, type: size > 0 ? 'image/jpeg' : '' }, + parsedName: mv, + mappingMatchValue: mv, + mappingFileName: mv !== undefined ? name : undefined, + }, + ...(size === 0 + ? { status: { type: 'cancelled' as const, reason: 'fileMissing' as const } } + : {}), + }) as PartialUploadableFileSpec; + + test('empty inputs produce only placeholders from CSV', () => { + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + [], + [], + CSV + ); + expect(duplicateFiles).toHaveLength(0); + expect(resolvedFiles).toHaveLength(2); + expect(resolvedFiles.every((r) => (r.status as any)?.reason === 'fileMissing')).toBe( + true + ); + }); + + test('existing real files not in new selection are preserved', () => { + const existing = [mk('a.jpg', 91, 'A')]; + const { resolvedFiles } = prepareMappingFileSelection( + existing, + [mk('b.jpg', 91, 'B')], + CSV + ); + expect(resolvedFiles).toHaveLength(2); + expect( + resolvedFiles.find((r) => r.uploadFile.file.name === 'a.jpg') + ).toBeTruthy(); + expect( + resolvedFiles.find((r) => r.uploadFile.file.name === 'b.jpg') + ).toBeTruthy(); + }); + + test('existing placeholders are NOT preserved', () => { + const placeholders = [ + mk('a.jpg', 0, 'A'), + mk('b.jpg', 0, 'B'), + ]; + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + placeholders, + [mk('a.jpg', 91, 'A')], + CSV + ); + expect(duplicateFiles).toHaveLength(0); + expect(resolvedFiles).toHaveLength(2); + // a.jpg should now be real (91 bytes), b.jpg should be a placeholder + const a = resolvedFiles.find((r) => r.uploadFile.file.name === 'a.jpg'); + const b = resolvedFiles.find((r) => r.uploadFile.file.name === 'b.jpg'); + expect(a?.uploadFile.file.size).toBe(91); + expect((b?.status as any)?.reason).toBe('fileMissing'); + }); + + test('duplicate files (same name/size/type) are detected', () => { + // Create a real File with 91 bytes so it matches the new entry + const content = new Uint8Array(91); + const existingFile = new File([content], 'a.jpg', { type: 'image/jpeg' }); + const existing = [ + { + uploadFile: { file: existingFile, parsedName: 'A', mappingMatchValue: 'A' }, + }, + ] as RA; + const { resolvedFiles, duplicateFiles } = prepareMappingFileSelection( + existing, + [mk('a.jpg', 91, 'A')], + CSV + ); + expect(duplicateFiles).toHaveLength(1); + expect(resolvedFiles).toHaveLength(2); // a (existing) + b (placeholder) + }); + + test('files with attachmentId are preserved through selection', () => { + const existing = [ + { + ...mk('a.jpg', 91, 'A'), + attachmentId: 99, + status: { type: 'success' as const, successType: 'uploaded' as const }, + }, + ] as RA; + + const { resolvedFiles } = prepareMappingFileSelection( + existing, + [mk('b.jpg', 91, 'B')], + CSV + ); + const a = resolvedFiles.find((r) => r.uploadFile.file.name === 'a.jpg'); + expect(a?.attachmentId).toBe(99); + expect(a?.status?.type).toBe('success'); + }); + + test('old rows without mappingMatchValue get it from CSV after selection', () => { + const savedRows = CSV.map( + (row) => + ({ + uploadFile: { + file: { name: row.fileName, size: 91, type: 'image/jpeg' }, + parsedName: row.matchValue, + }, + }) as PartialUploadableFileSpec + ); + + const { resolvedFiles } = prepareMappingFileSelection( + savedRows, + [], + CSV + ); + + for (const r of resolvedFiles) { + expect(r.uploadFile.mappingMatchValue).toBeTruthy(); + expect(r.uploadFile.parsedName).toBeTruthy(); + } + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts index b39a0addb72..6b0f9c3811f 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts @@ -10,6 +10,18 @@ import type { PartialAttachmentUploadSpec } from './Import'; import type { staticAttachmentImportPaths } from './importPaths'; import type { keyLocalizationMapAttachment } from './utils'; +export type MatchingMode = 'mappingFile'; + +export type MappingFileColumns = { + readonly matchValueColumnIndex: number; + readonly fileNameColumnIndex: number; +}; + +export type MappingFileRow = { + readonly matchValue: string; + readonly fileName: string; +}; + export type UploadAttachmentSpec = { readonly token: string; readonly attachmentLocation: string; @@ -62,6 +74,10 @@ export type BoundFile = Pick; export type UnBoundFile = { readonly file: BoundFile | File; readonly parsedName?: string; + /** Match value from mapping file CSV (used in mapping-file mode) */ + readonly mappingMatchValue?: string; + /** File name from mapping file CSV (used in mapping-file mode) */ + readonly mappingFileName?: string; }; export type AttachmentWorkProgress = { @@ -102,6 +118,7 @@ export type AttachmentDatasetBrief = DatasetBriefBase & { | 'uploading' | 'uploadInterrupted' | 'validating'; + readonly matchingmode?: MatchingMode | null; }; export type AttachmentDataSetPlan = AttachmentDatasetBrief & { diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts index 437bb9c25dc..09985bbf6fb 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/useEagerDataset.ts @@ -89,7 +89,14 @@ export function useEagerDataSet(baseDataSet: AttachmentDataSet): { needsSaved: baseDataSet.uploaderstatus !== 'main', rows: baseDataSet.rows ?? [], save: false, - uploadplan: generateUploadSpec(baseDataSet.uploadplan.staticPathKey), + uploadplan: { + ...generateUploadSpec(baseDataSet.uploadplan.staticPathKey), + matchingMode: + baseDataSet.matchingmode ?? + baseDataSet.uploadplan.matchingMode, + mappingFileColumns: baseDataSet.uploadplan.mappingFileColumns, + mappingFileData: baseDataSet.uploadplan.mappingFileData, + }, uploadresult: baseDataSet.uploadresult === undefined || baseDataSet.uploadresult === null diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts index 7030ecf7e33..233421b2afb 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts @@ -35,7 +35,11 @@ import { makeQueryField } from '../QueryBuilder/fromTree'; import type { QueryFieldWithPath } from '../Statistics/types'; import type { AttachmentUploadSpec } from './Import'; import { staticAttachmentImportPaths } from './importPaths'; -import type { AttachmentStatus, PartialUploadableFileSpec } from './types'; +import type { + AttachmentStatus, + MappingFileRow, + PartialUploadableFileSpec, +} from './types'; export type ResolvedAttachmentRecord = | State< @@ -63,10 +67,16 @@ const resolveAttachmentMatch = ( export function resolveAttachmentRecord( matchedId: RA | undefined, disambiguated: number | undefined, - parsedName: string | undefined + parsedName: string | undefined, + isMappingMode: boolean = false ): ResolvedAttachmentRecord { - if (parsedName === undefined) - return { type: 'invalid', reason: 'incorrectFormatter' }; + if (parsedName === undefined) { + // In mapping mode, undefined parsedName means not in mapping file + return { + type: 'invalid', + reason: isMappingMode ? 'notInMappingFile' : 'incorrectFormatter', + }; + } if (matchedId === undefined) return { type: 'valid', reason: 'correctlyFormatted' }; return resolveAttachmentMatch(matchedId, disambiguated); @@ -157,6 +167,13 @@ type MatchSelectedFiles = { readonly resolvedFiles: RA; readonly duplicateFiles: RA; }; + +export const isMappingFilePlaceholder = ( + uploadable: PartialUploadableFileSpec +): boolean => + uploadable.status?.type === 'cancelled' && + uploadable.status.reason === 'fileMissing'; + export const matchSelectedFiles = ( previousUploadables: RA, filesToResolve: RA @@ -222,6 +239,136 @@ export const matchSelectedFiles = ( } ); +export const prepareMappingFileSelection = ( + previousUploadables: RA, + filesToResolve: RA, + mappingData: RA +): MatchSelectedFiles => { + const previousRealFiles = previousUploadables.filter( + (uploadable) => !isMappingFilePlaceholder(uploadable) + ); + const { resolvedFiles, duplicateFiles } = matchSelectedFiles( + previousRealFiles, + filesToResolve + ); + + return { + resolvedFiles: crossReferenceMappingFiles(resolvedFiles, mappingData), + duplicateFiles, + }; +}; + +/** + * Cross-reference uploaded files against the mapping file CSV data. + * Builds the result from scratch: for each CSV row, picks the best + * available file (real upload > placeholder). Flags leftover files + * not in the mapping. Guarantees no duplicate filenames. + */ +export function crossReferenceMappingFiles( + uploadableFiles: RA, + mappingData: RA +): RA { + // Build a filename → best-row map (prefer real files over placeholders) + const byName = new Map(); + for (const f of uploadableFiles) { + const existing = byName.get(f.uploadFile.file.name); + if (existing === undefined) { + byName.set(f.uploadFile.file.name, f); + } else { + // Keep the "better" entry: real file > placeholder, + // File instance > plain object (serialized from server), + // row with mappingMatchValue > row without + const existingIsPlaceholder = + existing.status?.type === 'cancelled' && + existing.status.reason === 'fileMissing'; + const newIsPlaceholder = + f.status?.type === 'cancelled' && f.status.reason === 'fileMissing'; + if ( + (existingIsPlaceholder && !newIsPlaceholder) || + (!(existing.uploadFile.file instanceof File) && + f.uploadFile.file instanceof File) + ) { + byName.set(f.uploadFile.file.name, f); + } else if (!existingIsPlaceholder && !newIsPlaceholder) { + // Keep the first entry but flag it unless already uploaded. + byName.set(f.uploadFile.file.name, { + ...existing, + ...(existing.attachmentId === undefined && + existing.status?.type !== 'success' + ? { + status: { + type: 'cancelled' as const, + reason: 'duplicateInMappingFile' as const, + }, + } + : {}), + }); + } + } + } + + const result: PartialUploadableFileSpec[] = []; + const matchedFileNames = new Set(); + + // For each CSV row, emit the best available file or a placeholder + for (const row of mappingData) { + const existing = byName.get(row.fileName); + + if (existing !== undefined) { + // Only emit once per filename even if CSV has duplicate rows + if (matchedFileNames.has(row.fileName)) continue; + matchedFileNames.add(row.fileName); + result.push({ + ...existing, + uploadFile: { + ...existing.uploadFile, + // Always take the match value from the CSV – it is the source of truth + parsedName: row.matchValue, + mappingMatchValue: row.matchValue, + mappingFileName: row.fileName, + }, + }); + } else { + // File not yet uploaded — add placeholder (once per filename) + if (matchedFileNames.has(row.fileName)) continue; + matchedFileNames.add(row.fileName); + result.push({ + uploadFile: { + file: { name: row.fileName, size: 0, type: '' }, + parsedName: row.matchValue, + mappingMatchValue: row.matchValue, + mappingFileName: row.fileName, + }, + status: { + type: 'cancelled' as const, + reason: 'fileMissing' as const, + }, + }); + } + } + + // Flag any uploaded files not referenced by the CSV + for (const [name, file] of byName) { + if (!matchedFileNames.has(name)) { + const alreadyAttached = + file.attachmentId !== undefined || file.status?.type === 'success'; + result.push({ + ...file, + ...(alreadyAttached + ? {} + : { + status: { + type: 'cancelled' as const, + reason: 'notInMappingFile' as const, + }, + }), + }); + } + } + + return result; +} + export function resolveFileNames( fileName: string, getFormatted: (rawName: number | string | undefined) => string | undefined, @@ -479,6 +626,9 @@ export const keyLocalizationMapAttachment = { errorFetchingRecord: attachmentsText.errorFetchingRecord(), saveError: attachmentsText.errorSavingRecord(), attachmentUploadError: attachmentsText.attachmentUploadError(), + fileMissing: attachmentsText.fileMissing(), + notInMappingFile: attachmentsText.notInMappingFile(), + duplicateInMappingFile: attachmentsText.duplicateInMappingFile(), } as const; export function resolveAttachmentStatus( diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index d231d8d5069..9dc5a0f438c 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -920,4 +920,198 @@ export const attachmentsText = createDictionary({ 'Ovo kontrolira hoće li se novi privitci dodani u ovu kolekciju prema zadanim postavkama označavati kao "Javni". Javni privitci automatski će biti vidljivi na Navedite web portalu. Ova se postavka može poništiti za svaki pojedinačni privitak i ne utječe na postojeće privitke.', nb: 'Dette kontrollerer om nye vedlegg som legges til i denne samlingen skal flagges som «Offentlige» som standard. Offentlige vedlegg vil automatisk være synlige på en Specify-nettportal. Denne innstillingen kan overstyres for hvert vedlegg og påvirker ikke eksisterende vedlegg.', }, + matchingMode: { + 'en-us': 'Matching Mode', + 'de-ch': 'Abgleichsmodus', + 'es-es': 'Modo de coincidencia', + 'fr-fr': 'Mode de correspondance', + 'ru-ru': 'Режим сопоставления', + 'uk-ua': 'Режим зіставлення', + 'pt-br': 'Modo de correspondência', + 'hr-hr': 'Način podudaranja', + nb: 'Samsvarsmodus', + }, + matchByFilename: { + 'en-us': 'Match by filename', + 'de-ch': 'Nach Dateiname abgleichen', + 'es-es': 'Coincidir por nombre de archivo', + 'fr-fr': 'Correspondance par nom de fichier', + 'ru-ru': 'Сопоставление по имени файла', + 'uk-ua': 'Зіставлення за назвою файлу', + 'pt-br': 'Corresponder por nome de arquivo', + 'hr-hr': 'Podudaranje prema nazivu datoteke', + nb: 'Samsvar etter filnavn', + }, + matchByMappingFile: { + 'en-us': 'Match by mapping file', + 'de-ch': 'Nach Zuordnungsdatei abgleichen', + 'es-es': 'Coincidir por archivo de mapeo', + 'fr-fr': 'Correspondance par fichier de mappage', + 'ru-ru': 'Сопоставление по файлу маппинга', + 'uk-ua': 'Зіставлення за файлом зіставлення', + 'pt-br': 'Corresponder por arquivo de mapeamento', + 'hr-hr': 'Podudaranje prema datoteci mapiranja', + nb: 'Samsvar etter tilordningsfil', + }, + fileMissing: { + 'en-us': 'File Missing', + 'de-ch': 'Datei fehlt', + 'es-es': 'Archivo faltante', + 'fr-fr': 'Fichier manquant', + 'ru-ru': 'Файл отсутствует', + 'uk-ua': 'Файл відсутній', + 'pt-br': 'Arquivo ausente', + 'hr-hr': 'Datoteka nedostaje', + nb: 'Fil mangler', + }, + awaitingFile: { + 'en-us': 'Awaiting file', + }, + mappingAwaitingFiles: { + 'en-us': 'Please select your files, or drag and drop them, to begin.', + }, + notInMappingFile: { + 'en-us': 'Not in Mapping File', + 'de-ch': 'Nicht in der Zuordnungsdatei', + 'es-es': 'No está en el archivo de mapeo', + 'fr-fr': 'Pas dans le fichier de mappage', + 'ru-ru': 'Нет в файле маппинга', + 'uk-ua': 'Немає у файлі зіставлення', + 'pt-br': 'Não está no arquivo de mapeamento', + 'hr-hr': 'Nije u datoteci mapiranja', + nb: 'Ikke i tilordningsfil', + }, + duplicateInMappingFile: { + 'en-us': 'Duplicate in Mapping File', + 'de-ch': 'Duplikat in der Zuordnungsdatei', + 'es-es': 'Duplicado en el archivo de mapeo', + 'fr-fr': 'Doublon dans le fichier de mappage', + 'ru-ru': 'Дубликат в файле маппинга', + 'uk-ua': 'Дублікат у файлі зіставлення', + 'pt-br': 'Duplicado no arquivo de mapeamento', + 'hr-hr': 'Duplikat u datoteci mapiranja', + nb: 'Duplikat i tilordningsfil', + }, + selectMatchValueColumn: { + 'en-us': 'Match Value Column', + 'de-ch': 'Spalte für Abgleichswert', + 'es-es': 'Columna de valor de coincidencia', + 'fr-fr': 'Colonne de valeur de correspondance', + 'ru-ru': 'Колонка значения сопоставления', + 'uk-ua': 'Стовпець значення зіставлення', + 'pt-br': 'Coluna de valor de correspondência', + 'hr-hr': 'Stupac vrijednosti podudaranja', + nb: 'Kolonne for samsvarsverdi', + }, + selectFileNameColumn: { + 'en-us': 'File Name Column', + 'de-ch': 'Spalte für Dateinamen', + 'es-es': 'Columna de nombre de archivo', + 'fr-fr': 'Colonne de nom de fichier', + 'ru-ru': 'Колонка имени файла', + 'uk-ua': 'Стовпець назви файлу', + 'pt-br': 'Coluna de nome do arquivo', + 'hr-hr': 'Stupac naziva datoteke', + nb: 'Kolonne for filnavn', + }, + mappingFileLoaded: { + 'en-us': 'Mapping file loaded ({count:number} rows)', + 'de-ch': 'Zuordnungsdatei geladen ({count:number} Zeilen)', + 'es-es': 'Archivo de mapeo cargado ({count:number} filas)', + 'fr-fr': 'Fichier de mappage chargé ({count:number} lignes)', + 'ru-ru': 'Файл маппинга загружен ({count:number} строк)', + 'uk-ua': 'Файл зіставлення завантажено ({count:number} рядків)', + 'pt-br': 'Arquivo de mapeamento carregado ({count:number} linhas)', + 'hr-hr': 'Datoteka mapiranja učitana ({count:number} redaka)', + nb: 'Tilordningsfil lastet ({count:number} rader)', + }, + mappingFileColumnsInfo: { + 'en-us': 'Match: {matchColumn:string} → File: {fileColumn:string}', + 'de-ch': 'Abgleich: {matchColumn:string} → Datei: {fileColumn:string}', + 'es-es': + 'Coincidencia: {matchColumn:string} → Archivo: {fileColumn:string}', + 'fr-fr': + 'Correspondance: {matchColumn:string} → Fichier: {fileColumn:string}', + 'ru-ru': 'Сопоставление: {matchColumn:string} → Файл: {fileColumn:string}', + 'uk-ua': 'Зіставлення: {matchColumn:string} → Файл: {fileColumn:string}', + 'pt-br': + 'Correspondência: {matchColumn:string} → Arquivo: {fileColumn:string}', + 'hr-hr': + 'Podudaranje: {matchColumn:string} → Datoteka: {fileColumn:string}', + nb: 'Samsvar: {matchColumn:string} → Fil: {fileColumn:string}', + }, + chooseMatchingMode: { + 'en-us': + 'Choose how attachment files should be matched to database records.', + 'de-ch': + 'Wählen Sie, wie Anhangsdateien mit Datenbankeinträgen abgeglichen werden sollen.', + 'es-es': + 'Elija cómo se deben emparejar los archivos adjuntos con los registros de la base de datos.', + 'fr-fr': + 'Choisissez comment les pièces jointes doivent être associées aux enregistrements de la base de données.', + 'ru-ru': + 'Выберите способ сопоставления файлов вложений с записями базы данных.', + 'uk-ua': + 'Виберіть спосіб зіставлення файлів вкладень із записами бази даних.', + 'pt-br': + 'Escolha como os arquivos anexos devem ser combinados com os registros do banco de dados.', + 'hr-hr': + 'Odaberite kako se datoteke privitaka trebaju podudarati s zapisima baze podataka.', + nb: 'Velg hvordan vedleggsfiler skal matches mot databaseposter.', + }, + matchByFilenameDescription: { + 'en-us': + 'Parse the record identifier from each filename using a field formatter. Works best with structured numeric fields.', + 'de-ch': + 'Analysieren Sie die Datensatzkennung aus jedem Dateinamen mit einem Feldformatierer. Funktioniert am besten mit strukturierten, numerischen Feldern.', + 'es-es': + 'Analice el identificador de registro de cada nombre de archivo utilizando un formateador de campo. Funciona mejor con campos numéricos estructurados.', + 'fr-fr': + "Analysez l'identifiant d'enregistrement à partir de chaque nom de fichier à l'aide d'un formateur de champ. Fonctionne mieux avec des champs numériques structurés.", + 'ru-ru': + 'Извлеките идентификатор записи из каждого имени файла с помощью форматировщика полей. Лучше всего работает со структурированными числовыми полями.', + 'uk-ua': + 'Витягніть ідентифікатор запису з кожної назви файлу за допомогою форматувальника полів. Найкраще працює зі структурованими числовими полями.', + 'pt-br': + 'Analise o identificador de registro de cada nome de arquivo usando um formatador de campo. Funciona melhor com campos numéricos estruturados.', + 'hr-hr': + 'Analizirajte identifikator zapisa iz svakog naziva datoteke pomoću formatatora polja. Najbolje radi s strukturiranim, numeričkim poljima.', + nb: 'Analyser postidentifikatoren fra hvert filnavn ved hjelp av en feltformaterer. Fungerer best med strukturerte, numeriske felt.', + }, + matchByMappingFileDescription: { + 'en-us': + 'Use an explicit CSV mapping file that lists which record each file belongs to.', + 'de-ch': + 'Verwenden Sie eine explizite CSV-Zuordnungsdatei, die angibt, zu welchem Datensatz jede Datei gehört.', + 'es-es': + 'Utilice un archivo de mapeo CSV explícito que indique a qué registro pertenece cada archivo.', + 'fr-fr': + 'Utilisez un fichier de mappage CSV explicite qui indique à quel enregistrement appartient chaque fichier.', + 'ru-ru': + 'Используйте явный CSV-файл сопоставления, который указывает, к какой записи принадлежит каждый файл.', + 'uk-ua': + 'Використовуйте явний CSV-файл зіставлення, який вказує, до якого запису належить кожен файл.', + 'pt-br': + 'Use um arquivo de mapeamento CSV explícito que liste a qual registro cada arquivo pertence.', + 'hr-hr': + 'Koristite eksplicitnu CSV datoteku mapiranja koja navodi kojem zapisu pripada svaka datoteka.', + nb: 'Bruk en eksplisitt CSV-tilordningsfil som viser hvilken post hver fil tilhører.', + }, + matchValue: { + 'en-us': 'Match Value', + 'de-ch': 'Abgleichswert', + 'es-es': 'Valor de coincidencia', + 'fr-fr': 'Valeur de correspondance', + 'ru-ru': 'Значение сопоставления', + 'uk-ua': 'Значення зіставлення', + 'pt-br': 'Valor de correspondência', + 'hr-hr': 'Vrijednost podudaranja', + nb: 'Samsvarsverdi', + }, + csvParseError: { + 'en-us': 'Failed to parse CSV file', + }, + csvEmptyFile: { + 'en-us': 'Empty CSV file', + }, } as const);