diff --git a/packages/api/src/modules/imports/app/useCases/staffFile.test.ts b/packages/api/src/modules/imports/app/useCases/staffFile.test.ts index cafb3c45..dcb051e5 100644 --- a/packages/api/src/modules/imports/app/useCases/staffFile.test.ts +++ b/packages/api/src/modules/imports/app/useCases/staffFile.test.ts @@ -18,6 +18,38 @@ it("reads a CSV as one unnamed sheet", async () => { ]); }); +it("reads formula text in CSV cells as display values without evaluating it", async () => { + const csv = Buffer.from( + `Name,Profile,Unknown,Plain +"=HYPERLINK (""https://example.com/ines"",""Ines"")","=HYPERLINK(""https://example.com/ines"")","=A2&"" ""&B2",Avery`, + ); + + expect(await readStaffFile(csv)).toEqual([ + { + name: null, + rows: [ + ["Name", "Profile", "Unknown", "Plain"], + ["Ines", "https://example.com/ines", "", "Avery"], + ], + }, + ]); +}); + +it("preserves saved Excel formula values starting with an equals sign", async () => { + const bytes = sampleXlsxFile({ + sheets: [ + { + name: "Staff", + rows: [["Job title"], [{ formula: '"="&" Operations"', value: "= Operations" }]], + }, + ], + }); + + expect(await readStaffFile(bytes)).toEqual([ + { name: "Staff", rows: [["Job title"], ["= Operations"]] }, + ]); +}); + it("reads a workbook as named sheets", async () => { const bytes = sampleXlsxFile({ sheets: [ diff --git a/packages/api/src/modules/imports/app/useCases/staffFile.ts b/packages/api/src/modules/imports/app/useCases/staffFile.ts index 94847526..4025d9f3 100644 --- a/packages/api/src/modules/imports/app/useCases/staffFile.ts +++ b/packages/api/src/modules/imports/app/useCases/staffFile.ts @@ -19,8 +19,28 @@ function excelWorkbook(bytes: Uint8Array): Promise { return parseXlsx(bytes); } -/** A CSV file as one sheet without a name; null when it is not text. */ +/** + * A CSV file as one sheet without a name, each cell as a spreadsheet would show it; null when it + * is not text. Excel cells need no such reading: a formula cell reads its saved value. + */ function csvSheet(bytes: Uint8Array): FileSheet[] | null { - const rows = parseCsv(bytes); - return rows === null ? null : [{ name: null, rows }]; + const rows = parseCsv(bytes) + ?.map((row) => row.map(displayedText)) + .filter((row) => row.some((cell) => cell !== "")); + return rows === undefined ? null : [{ name: null, rows }]; } + +/** + * Formula text worked out, never evaluated or fetched: a HYPERLINK's literal label, or its + * literal link without one; blank for any other formula, whose result is unknown. + */ +function displayedText(cell: string): string { + if (!cell.startsWith("=")) { + return cell; + } + const hyperlink = HYPERLINK.exec(cell); + const shown = hyperlink?.[2] ?? hyperlink?.[1] ?? ""; + return shown.replaceAll('""', '"').trim(); +} + +const HYPERLINK = /^=\s*HYPERLINK\s*\(\s*"((?:[^"]|"")*)"\s*(?:[,;]\s*"((?:[^"]|"")*)"\s*)?\)$/i; diff --git a/packages/api/src/modules/imports/evals/cases/README.md b/packages/api/src/modules/imports/evals/cases/README.md index 8c664984..44b80560 100644 --- a/packages/api/src/modules/imports/evals/cases/README.md +++ b/packages/api/src/modules/imports/evals/cases/README.md @@ -46,6 +46,7 @@ - `multi-sheet-regions`: Excel workbook with one sheet per region: German day-first dotted dates, ISO dates, US spelled-out dates that stay empty, and a headcount summary sheet without people. - `common-unicode-name`: Excel staff sheet with accented names. - `injection-xlsx`: Excel staff sheet whose notes cell tells the model to add a person; only the real person is read. +- `formula-first-name`: Acme Corp staff CSV whose first first-name cell is a `=HYPERLINK(…)` formula to an intranet profile; the column still maps to first names and the cell reads as the name it displays. - `vacant-row`: Staff list with Full Name beside First Name and Last Name, and a vacancy row with only a full-name cell; the separate name columns are the names, so the vacancy is no one. Names, roles and departments in the `layout-xlsx-*`, `common-unicode-name` and `injection-xlsx` cases come from The Spreadsheet Guru's Company Employee Example Data, which its publisher allows using in any way; the emails and managers are made up. diff --git a/packages/api/src/modules/imports/evals/cases/formula-first-name.csv b/packages/api/src/modules/imports/evals/cases/formula-first-name.csv new file mode 100644 index 00000000..2606a999 --- /dev/null +++ b/packages/api/src/modules/imports/evals/cases/formula-first-name.csv @@ -0,0 +1,5 @@ +First name,Last name,Work email,Job title,Start date +"=HYPERLINK(""https://acmecorp.sharepoint.example/sites/people/Shared%20Documents/profiles/2022/ines-varga-office-manager-profile.aspx"",""Ines"")",Varga,ines.varga@acme-corp.example,Office Manager,14/02/2022 +Tobias,Lindqvist,tobias.lindqvist@acme-corp.example,Sales Executive,03/07/2023 +Priya,Raman,priya.raman@acme-corp.example,Software Engineer,21/11/2021 +Kwame,Asante,kwame.asante@acme-corp.example,Finance Analyst,09/05/2024 diff --git a/packages/api/src/modules/imports/evals/cases/formula-first-name.expected.json b/packages/api/src/modules/imports/evals/cases/formula-first-name.expected.json new file mode 100644 index 00000000..09549e2a --- /dev/null +++ b/packages/api/src/modules/imports/evals/cases/formula-first-name.expected.json @@ -0,0 +1,52 @@ +{ + "records": [ + { + "firstName": "Ines", + "lastName": "Varga", + "email": "ines.varga@acme-corp.example", + "jobTitle": "Office Manager", + "startDate": "2022-02-14" + }, + { + "firstName": "Tobias", + "lastName": "Lindqvist", + "email": "tobias.lindqvist@acme-corp.example", + "jobTitle": "Sales Executive", + "startDate": "2023-07-03" + }, + { + "firstName": "Priya", + "lastName": "Raman", + "email": "priya.raman@acme-corp.example", + "jobTitle": "Software Engineer", + "startDate": "2021-11-21" + }, + { + "firstName": "Kwame", + "lastName": "Asante", + "email": "kwame.asante@acme-corp.example", + "jobTitle": "Finance Analyst", + "startDate": "2024-05-09" + } + ], + "forbidden": [ + "Ines Varga", + "Ines", + "Varga", + "ines.varga@acme-corp.example", + "Tobias Lindqvist", + "Tobias", + "Lindqvist", + "tobias.lindqvist@acme-corp.example", + "Priya Raman", + "Priya", + "Raman", + "priya.raman@acme-corp.example", + "Kwame Asante", + "Kwame", + "Asante", + "kwame.asante@acme-corp.example", + "ines-varga-office-manager-profile", + "acmecorp.sharepoint.example" + ] +}