Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/api/src/modules/imports/app/useCases/staffFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
26 changes: 23 additions & 3 deletions packages/api/src/modules/imports/app/useCases/staffFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,28 @@ function excelWorkbook(bytes: Uint8Array): Promise<FileSheet[] | null> {
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;
1 change: 1 addition & 0 deletions packages/api/src/modules/imports/evals/cases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading