diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 32d32acdd1d..bd37c289414 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -236,7 +236,12 @@ function Attachments({ navigate('/specify/overlay/attachments/import/')} > - {commonText.import()} + {attachmentsText.importMatchingAttachments()} + + navigate('/specify/attachments/import//')} + > + {attachmentsText.importFromMappingFile()} )} diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx index fda1cfda79d..f733fe586d7 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Datasets.tsx @@ -32,6 +32,7 @@ import type { AttachmentDataSet, AttachmentDataSetPlan, FetchedDataSet, + PartialUploadableFileSpec, } from './types'; import { useEagerDataSet } from './useEagerDataset'; @@ -97,12 +98,21 @@ function ModifyDataset({ ); } -const createEmpty = async (name: LocalizedString) => +const createEmpty = async ( + name: LocalizedString, + rows: RA = [] +) => createEmptyDataSet('bulkAttachment', name, { uploadplan: { staticPathKey: undefined }, uploaderstatus: 'main', + rows, }); +export const createEmptyAttachmentDataset = ( + name: LocalizedString, + rows: RA = [] +) => createEmpty(name, rows); + export function AttachmentsImportOverlay(): JSX.Element | null { const handleClose = React.useContext(OverlayContext); const attachmentDataSetsPromise = React.useMemo(fetchAttachmentMappings, []); @@ -274,7 +284,7 @@ function NewDataSet(): JSX.Element | null { id={id('form')} onSubmit={async () => { loading( - createEmpty(pendingName).then(({ id }) => + createEmptyAttachmentDataset(pendingName).then(({ id }) => navigate(`/specify/attachments/import/${id}`) ) ); diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx index 7bd0afe0fbd..10a8d2818b8 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx @@ -13,7 +13,7 @@ import { ajax } from '../../utils/ajax'; import { f } from '../../utils/functools'; import type { RA, WritableArray } from '../../utils/types'; import type { IR } from '../../utils/types'; -import { removeKey, sortFunction } from '../../utils/utils'; +import { removeKey, sortFunction, stripFileExtension } from '../../utils/utils'; import { Container } from '../Atoms'; import { Button } from '../Atoms/Button'; import { strictGetTable } from '../DataModel/tables'; @@ -132,19 +132,37 @@ 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, + preserveParseName = true + ): PartialUploadableFileSpec => { + if (eagerDataSet.uploadplan.staticPathKey === undefined) + return { uploadFile: file }; + let parsedName = undefined; + + if (file.parsedName !== undefined && preserveParseName) + // Maybe we are being too nice. Users should correctly format this? + // This wil also correctly handle the case where formatter is no longer valid + // because it was changed. + parsedName = resolveFileNames( + file.parsedName, + eagerDataSet.uploadplan.formatQueryResults, + eagerDataSet.uploadplan.fieldFormatter + ); + if (parsedName === undefined) + parsedName = resolveFileNames( + stripFileExtension(file.file.name), + eagerDataSet.uploadplan.formatQueryResults, + eagerDataSet.uploadplan.fieldFormatter + ); + + return { + uploadFile: { + ...file, + parsedName, + }, + }; + }, [eagerDataSet.uploadplan.staticPathKey] ); @@ -154,10 +172,15 @@ function AttachmentsImport({ React.useEffect(() => { // Reset all parsed names if matching path is changed if (previousKeyRef.current !== eagerDataSet.uploadplan.staticPathKey) { - previousKeyRef.current = eagerDataSet.uploadplan.staticPathKey; commitFileChange((files) => - files.map(({ uploadFile }) => applyFileNames(uploadFile)) + files.map(({ uploadFile }) => + // Do not preserve parsed name if previous static key was not undefined. + // Otherwise, a numeric formatter can be applied after a text formatter and we will match + // records incorrectly + applyFileNames(uploadFile, previousKeyRef.current === undefined) + ) ); + previousKeyRef.current = eagerDataSet.uploadplan.staticPathKey; } }, [applyFileNames, commitFileChange]); @@ -176,7 +199,11 @@ function AttachmentsImport({ ); const handleFilesSelected = (files: FileList) => { - const filesList = Array.from(files, (file) => applyFileNames({ file })); + const filesList: RA = Array.from( + files, + (file) => ({ uploadFile: { file } }) + ); + const oldRows = eagerDataSet.rows; const { resolvedFiles, duplicateFiles } = matchSelectedFiles( oldRows, @@ -185,10 +212,13 @@ function AttachmentsImport({ (resolvedFiles as WritableArray).sort( sortFunction((file) => file.uploadFile.file.name) ); + const fileNamesApplied = resolvedFiles.map(({ uploadFile }) => + applyFileNames(uploadFile) + ); commitChange((oldState) => ({ ...oldState, uploaderstatus: 'main', - rows: resolvedFiles, + rows: fileNamesApplied, })); setDuplicatedFiles(duplicateFiles); }; diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx new file mode 100644 index 00000000000..7bc882442b9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ImportFromMappingFile.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { localized, RA } from '../../utils/types'; +import { CsvFilePicker } from '../Molecules/CsvFilePicker'; +import { attachmentsText } from '../../localization/attachments'; +import { useNavigate } from 'react-router-dom'; + +import { PartialUploadableFileSpec } from './types'; +import { createEmptyAttachmentDataset } from './Datasets'; + +// TODO: Is this needed? +//const requiredHeaders = ['filename', 'identifier']; + +export function ImportFromMappingFile(): JSX.Element { + const navigate = useNavigate(); + return ( + <> + { + // To support mapping file, we just create a new dataset with files. Matching behaviour + // within dataset takes care of rest + const attachmentRows: RA = rawRows.map( + ([fileName, parsedName]) => { + const trimmedFileName = + fileName === undefined ? '' : String(fileName).trim(); + const trimmedParsedName = + parsedName === undefined ? undefined : String(parsedName).trim(); + + return { + uploadFile: { + file: { + name: trimmedFileName, + }, + parsedName: trimmedParsedName, + }, + }; + } + ); + createEmptyAttachmentDataset( + localized(fileName), + attachmentRows + ).then(({ id }) => navigate(`/specify/attachments/import/${id}`)); + }} + /> + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx index 276b19add08..06b41e19ba2 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx @@ -63,7 +63,10 @@ const resolveAttachmentDatasetData = ( {uploadFile.file.name} , ], - fileSize: formatFileSize(uploadFile.file.size), + fileSize: + uploadFile.file.size === undefined + ? '' + : formatFileSize(uploadFile.file.size), record: [ resolvedRecord?.type === 'matched' ? resolvedRecord.id diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx new file mode 100644 index 00000000000..93c0e4fa335 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/ImportFromMappingFile.test.tsx @@ -0,0 +1,155 @@ +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import fs from 'fs'; +import path from 'path'; +import { jest } from '@jest/globals'; + +import { mount } from '../../../tests/reactUtils'; +import { LoadingContext } from '../../Core/Contexts'; +import { requireContext } from '../../../tests/helpers'; +import * as Datasets from '../Datasets'; +import { ImportFromMappingFile } from '../ImportFromMappingFile'; + +requireContext(); + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +type FileType = 'text/csv' | 'image/jpeg' | 'image/png' | 'text/plain'; + +const fixturesRoot = path.join( + __dirname, + '../../../tests/fixtures/attachmentsImport' +); + +function fixtureFile(name: string): File { + const fileBuffer = fs.readFileSync(path.join(fixturesRoot, name)); + const type: FileType = name.endsWith('.png') + ? 'image/png' + : name.endsWith('.jpg') || name.endsWith('.jpeg') + ? 'image/jpeg' + : name.endsWith('.csv') + ? 'text/csv' + : 'text/plain'; + + return new File([fileBuffer], name, { type }); +} + +describe('ImportFromMappingFile', () => { + beforeEach(() => { + const portalRoot = document.createElement('div'); + portalRoot.id = 'portal-root'; + document.body.appendChild(portalRoot); + + mockNavigate.mockClear(); + jest + .spyOn(Datasets, 'createEmptyAttachmentDataset') + .mockResolvedValue({ id: 123 } as any); + }); + + afterEach(() => { + const portalRoot = document.getElementById('portal-root'); + if (portalRoot !== null) portalRoot.remove(); + jest.restoreAllMocks(); + }); + + test('creates attachment dataset from a valid CSV mapping file', async () => { + const { container, user } = mount( + promise as Promise}> + + + ); + + const fileInput = container.querySelector('input[type=file]'); + expect(fileInput).toBeTruthy(); + + const mappingFile = fixtureFile('mapping-file-valid.csv'); + await user.upload(fileInput!, mappingFile); + + const importButton = container.getElementsByTagName('button')[0]; + expect(importButton).toBeTruthy(); + + await waitFor(() => expect(importButton).not.toBeDisabled()); + await user.click(importButton!); + + await waitFor(() => { + expect(Datasets.createEmptyAttachmentDataset).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + uploadFile: expect.objectContaining({ + file: expect.objectContaining({ name: 'fake-image-1.jpg' }), + parsedName: 'ID_001', + }), + }), + expect.objectContaining({ + uploadFile: expect.objectContaining({ + file: expect.objectContaining({ name: 'fake-image-2.png' }), + parsedName: 'ID_002', + }), + }), + ]) + ); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/specify/attachments/import/123'); + }); + + test('parses pipe-delimited CSV mapping files with trimmed fields', async () => { + const { container, user } = mount( + promise as Promise}> + + + ); + + const fileInput = container.querySelector('input[type=file]'); + expect(fileInput).toBeTruthy(); + + const mappingFile = fixtureFile('mapping-file-pipe-delimited.csv'); + await user.upload(fileInput!, mappingFile); + + const importButton = container.getElementsByTagName('button')[0]; + expect(importButton).toBeTruthy(); + + await waitFor(() => expect(importButton).not.toBeDisabled()); + await user.click(importButton!); + + await waitFor(() => { + expect(Datasets.createEmptyAttachmentDataset).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + uploadFile: expect.objectContaining({ + file: expect.objectContaining({ name: 'fake-image-3.jpg' }), + parsedName: 'ID_003', + }), + }), + expect.objectContaining({ + uploadFile: expect.objectContaining({ + file: expect.objectContaining({ name: 'fake-image-4.png' }), + parsedName: 'ID_004', + }), + }), + ]) + ); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/specify/attachments/import/123'); + }); + + test('fixture image files are readable and expose correct file metadata', () => { + const jpegFile = fixtureFile('fake-image-1.jpg'); + const pngFile = fixtureFile('fake-image-2.png'); + + expect(jpegFile.name).toBe('fake-image-1.jpg'); + expect(jpegFile.type).toBe('image/jpeg'); + expect(jpegFile.size).toBeGreaterThan(0); + + expect(pngFile.name).toBe('fake-image-2.png'); + expect(pngFile.type).toBe('image/png'); + expect(pngFile.size).toBeGreaterThan(0); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts index b39a0addb72..6426f160684 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/types.ts @@ -57,7 +57,8 @@ type UploadableFileSpec = { * Forcing keys to be primitive because objects would be * ignored during syncing to backend. */ -export type BoundFile = Pick; +export type BoundFile = Pick & + Partial>; export type UnBoundFile = { readonly file: BoundFile | File; diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts index 7030ecf7e33..86174a2263f 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts @@ -15,7 +15,6 @@ import { keysToLowerCase, mappedFind, replaceItem, - stripFileExtension, } from '../../utils/utils'; import { addMissingFields } from '../DataModel/addMissingFields'; import type { @@ -157,6 +156,7 @@ type MatchSelectedFiles = { readonly resolvedFiles: RA; readonly duplicateFiles: RA; }; + export const matchSelectedFiles = ( previousUploadables: RA, filesToResolve: RA @@ -168,10 +168,14 @@ export const matchSelectedFiles = ( previousUploadable.uploadFile !== undefined && previousUploadable.uploadFile.file.name === uploadable.uploadFile.file.name && - previousUploadable.uploadFile.file.size === - uploadable.uploadFile.file.size && - previousUploadable.uploadFile.file.type === - uploadable.uploadFile.file.type + // If previous uploadables do not have size, skip them. + // Happens when mapping file is being used + (previousUploadable.uploadFile.file.size === undefined || + previousUploadable.uploadFile.file.size === + uploadable.uploadFile.file.size) && + (previousUploadable.uploadFile.file.type === undefined || + previousUploadable.uploadFile.file.type === + uploadable.uploadFile.file.type) ); if (matchedIndex === -1) return { @@ -189,6 +193,7 @@ export const matchSelectedFiles = ( duplicateFiles: [...previousMatchedSpec.duplicateFiles, uploadable], }; + // We get here if we are going to preserve the incoming file. return { ...previousMatchedSpec, resolvedFiles: replaceItem( @@ -196,15 +201,17 @@ export const matchSelectedFiles = ( matchedIndex, { ...previousMatch, - uploadFile: uploadable.uploadFile, + uploadFile: { + file: uploadable.uploadFile.file, + parsedName: previousMatch.uploadFile.parsedName, + }, /* * Generating tokens again because the file could have been * uploaded to the asset server but not yet recorded in Specify DB. */ uploadTokenSpec: undefined, /* - * Take the new status in case of parse failure was reported. - * But take the previous status it was a success + * Take the previous status it was a success */ status: previousMatch.status?.type === 'success' || @@ -228,7 +235,7 @@ export function resolveFileNames( formatter?: UiFormatter ): string | undefined { // BUG: Won't catch if formatters begin or end with a space - const splitName = stripFileExtension(fileName).trim(); + const splitName = fileName.trim(); let nameToParse = splitName; if (formatter?.parts.every((field) => field.type !== 'regex') === true) { nameToParse = fileName.trim().slice(0, formatter.size); diff --git a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx index fd16ebd5181..e73b07a0bb8 100644 --- a/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/LocalityUpdate/index.tsx @@ -81,7 +81,7 @@ export function LocalityUpdateFromDataSet(): JSX.Element { { + onFileImport={({ headers, data }): void => { const foundHeaderErrors = headers.reduce( (accumulator, currentHeader) => { const parsedHeader = currentHeader diff --git a/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx b/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx index 7d574550fe2..27402712294 100644 --- a/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx +++ b/specifyweb/frontend/js_src/lib/components/Molecules/CsvFilePicker.tsx @@ -27,10 +27,11 @@ export function CsvFilePicker({ }: { readonly header: LocalizedString; readonly firstRowAlwaysHeader?: boolean; - readonly onFileImport: ( - headers: RA, - data: RA> - ) => void; + readonly onFileImport: (payload: { + readonly headers: RA; + readonly data: RA>; + readonly fileName: string; + }) => void; }): JSX.Element { const [file, setFile] = React.useState(); const getSetHasHeader = useStateForContext(true); @@ -55,7 +56,11 @@ export function CsvFilePicker({ parseCsv(file, encoding, getSetDelimiter).then((data) => { const { header, rows } = extractHeader(data, hasHeader); - return void handleFileImport(header, rows); + return void handleFileImport({ + headers: header, + data: rows, + fileName: file.name, + }); }) ); }} @@ -79,11 +84,13 @@ export function CsvFilePreview({ hasHeader, encoding, getSetDelimiter, + fileName, }: { readonly data: RA>; readonly hasHeader: boolean; readonly encoding: string; readonly getSetDelimiter: GetOrSet; + readonly fileName: string; }) => void; }): JSX.Element { const [encoding, setEncoding] = React.useState('utf-8'); @@ -104,6 +111,7 @@ export function CsvFilePreview({ hasHeader, encoding, getSetDelimiter, + fileName: file.name, }); }} > diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 4b8ca180f5a..50ae9a66645 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -200,6 +200,13 @@ export const routes: RA = [ ({ AttachmentImportById }) => AttachmentImportById ), }, + { + path: 'import', + element: () => + import('../AttachmentsBulkImport/ImportFromMappingFile').then( + ({ ImportFromMappingFile }) => ImportFromMappingFile + ), + }, ], }, { diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 730265cdeb5..a6d6bd46338 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -31,6 +31,12 @@ export const attachmentsText = createDictionary({ 'hr-hr': 'Skala', nb: 'Skala', }, + importFromMMappingFile: { + 'en-us': 'Import from Mapping File', + }, + importMatchingAttachments: { + 'en-us': 'Import Matching Attachments', + }, attachmentServerUnavailable: { 'en-us': 'Attachment server unavailable', 'ru-ru': 'Сервер прикрепленных файлов недоступен', @@ -920,4 +926,7 @@ 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.', }, + importFromMappingFile: { + 'en-us': 'Import from Mapping File', + }, } as const); diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg new file mode 100644 index 00000000000..8c17e314a86 Binary files /dev/null and b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-1.jpg differ diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png new file mode 100644 index 00000000000..3ae65e62643 Binary files /dev/null and b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/fake-image-2.png differ diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv new file mode 100644 index 00000000000..fac1b77bd3a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-pipe-delimited.csv @@ -0,0 +1,19 @@ +filename|parsedName +fake-image-3.jpg|ID_003 + fake-image-4.png | ID_004 +"fake-image|5.png"|"ID_005|EXTRA" +"fake-image-6.jpg"| "ID_006" +fake-image-7.jpg|ID_007 +fake-image-8.jpg|ID_008 +"fake-image-9.jpg"|"ID_009" + fake-image-10.png | ID_010 +"weird|name|11.jpg"|"ID_011" +"trailing-space.jpg"|"ID_012" + "leading-space.png" | "ID_013" +"quoted,comma.jpg"|ID_014 +fake-image-15.jpg|"ID_015" +"semi;colon.jpg"|"ID_016" +"pipe|and,comma.jpg"|"ID_017|EXTRA" +"quoted-with-\"embedded\".jpg"|"ID_018" +fake-image-19.jpg|ID_019 +fake-image-20.png|ID_020 diff --git a/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv new file mode 100644 index 00000000000..558d6134adb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/tests/fixtures/attachmentsImport/mapping-file-valid.csv @@ -0,0 +1,6 @@ +filename,parsedName +fake-image-1.jpg,ID_001 + "fake-image-2.png" , "ID_002" +"fake-image,3.jpg",ID_003 +fake-image-4.png,"ID_004" +"fake-image-5.jpg","ID_005"