From 5eb9d5212509061f95f593f40a83f3007832b9a7 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Fri, 23 Oct 2026 12:38:55 +0200 Subject: [PATCH 1/3] test(imports): add a formula first-name eval case --- .../src/modules/imports/evals/cases/README.md | 1 + .../evals/cases/formula-first-name.csv | 5 ++ .../cases/formula-first-name.expected.json | 52 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 packages/api/src/modules/imports/evals/cases/formula-first-name.csv create mode 100644 packages/api/src/modules/imports/evals/cases/formula-first-name.expected.json 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" + ] +} From 38a6a861bd5f55866ca37a387cac0dddaa2482a6 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Fri, 23 Oct 2026 12:40:50 +0200 Subject: [PATCH 2/3] fix(imports): read formula cells as the text they display --- .../imports/app/useCases/staffFile.test.ts | 30 +++++++++++++++++++ .../modules/imports/app/useCases/staffFile.ts | 27 ++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) 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..b1aecd54 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,36 @@ 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("reads HYPERLINK text in Excel string cells as its label", async () => { + const bytes = sampleXlsxFile({ + sheets: [ + { + name: "Staff", + rows: [["Name"], ['=hyperlink( "https://example.com/ines" ; "Ines ""Ivy""" )']], + }, + ], + }); + + expect(await readStaffFile(bytes)).toEqual([{ name: "Staff", rows: [["Name"], ['Ines "Ivy"']] }]); +}); + 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..1a3be841 100644 --- a/packages/api/src/modules/imports/app/useCases/staffFile.ts +++ b/packages/api/src/modules/imports/app/useCases/staffFile.ts @@ -4,9 +4,34 @@ import { checkSheets, type FileSheet, type SheetRefusal } from "../../domain"; /** A staff file's sheets: a CSV file's one, or an Excel workbook's visible ones, within the limits. */ export async function readStaffFile(bytes: Uint8Array): Promise { const sheets = isExcelWorkbook(bytes) ? await excelWorkbook(bytes) : csvSheet(bytes); - return sheets === null ? "unsupported_file" : checkSheets(sheets); + return sheets === null ? "unsupported_file" : checkSheets(sheets.map(withDisplayedText)); } +/** Formula text, which a CSV or an Excel text cell can hold, as the text it shows. */ +function withDisplayedText({ name, rows }: FileSheet): FileSheet { + return { + name, + rows: rows + .map((row) => row.map(displayedText)) + .filter((row) => row.some((cell) => cell !== "")), + }; +} + +/** + * Worked out from the text, 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*)?\)$/i; + /** Every Excel workbook is a ZIP archive, which starts with this signature. */ const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04]; From 5c04ec00c4802d6283347fd2bfeceb1c2d9fce33 Mon Sep 17 00:00:00 2001 From: Artem Kireev Date: Fri, 23 Oct 2026 12:45:34 +0200 Subject: [PATCH 3/3] fix(imports): keep saved Excel values and allow a space before HYPERLINK's bracket --- .../imports/app/useCases/staffFile.test.ts | 10 ++-- .../modules/imports/app/useCases/staffFile.ts | 53 +++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) 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 b1aecd54..dcb051e5 100644 --- a/packages/api/src/modules/imports/app/useCases/staffFile.test.ts +++ b/packages/api/src/modules/imports/app/useCases/staffFile.test.ts @@ -21,7 +21,7 @@ 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`, +"=HYPERLINK (""https://example.com/ines"",""Ines"")","=HYPERLINK(""https://example.com/ines"")","=A2&"" ""&B2",Avery`, ); expect(await readStaffFile(csv)).toEqual([ @@ -35,17 +35,19 @@ it("reads formula text in CSV cells as display values without evaluating it", as ]); }); -it("reads HYPERLINK text in Excel string cells as its label", async () => { +it("preserves saved Excel formula values starting with an equals sign", async () => { const bytes = sampleXlsxFile({ sheets: [ { name: "Staff", - rows: [["Name"], ['=hyperlink( "https://example.com/ines" ; "Ines ""Ivy""" )']], + rows: [["Job title"], [{ formula: '"="&" Operations"', value: "= Operations" }]], }, ], }); - expect(await readStaffFile(bytes)).toEqual([{ name: "Staff", rows: [["Name"], ['Ines "Ivy"']] }]); + expect(await readStaffFile(bytes)).toEqual([ + { name: "Staff", rows: [["Job title"], ["= Operations"]] }, + ]); }); it("reads a workbook as named sheets", async () => { diff --git a/packages/api/src/modules/imports/app/useCases/staffFile.ts b/packages/api/src/modules/imports/app/useCases/staffFile.ts index 1a3be841..4025d9f3 100644 --- a/packages/api/src/modules/imports/app/useCases/staffFile.ts +++ b/packages/api/src/modules/imports/app/useCases/staffFile.ts @@ -4,34 +4,9 @@ import { checkSheets, type FileSheet, type SheetRefusal } from "../../domain"; /** A staff file's sheets: a CSV file's one, or an Excel workbook's visible ones, within the limits. */ export async function readStaffFile(bytes: Uint8Array): Promise { const sheets = isExcelWorkbook(bytes) ? await excelWorkbook(bytes) : csvSheet(bytes); - return sheets === null ? "unsupported_file" : checkSheets(sheets.map(withDisplayedText)); + return sheets === null ? "unsupported_file" : checkSheets(sheets); } -/** Formula text, which a CSV or an Excel text cell can hold, as the text it shows. */ -function withDisplayedText({ name, rows }: FileSheet): FileSheet { - return { - name, - rows: rows - .map((row) => row.map(displayedText)) - .filter((row) => row.some((cell) => cell !== "")), - }; -} - -/** - * Worked out from the text, 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*)?\)$/i; - /** Every Excel workbook is a ZIP archive, which starts with this signature. */ const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04]; @@ -44,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;