diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts index 0c50abfc8f..f78f011f09 100644 --- a/apps/web/__tests__/unit/loom-csv.test.ts +++ b/apps/web/__tests__/unit/loom-csv.test.ts @@ -33,6 +33,66 @@ describe("concierge Loom CSV", () => { }); }); +describe("Loom CSV delimiters", () => { + it("reads semicolon separated files", () => { + expect( + parseLoomCsvRecords( + "\uFEFFloom_video_url;user_email\r\n" + + "https://www.loom.com/share/0123456789abcdef;owner@example.com\r\n", + ), + ).toEqual([ + ["loom_video_url", "user_email"], + ["https://www.loom.com/share/0123456789abcdef", "owner@example.com"], + ]); + }); + + it("keeps quoted commas inside semicolon separated cells", () => { + const rows = parseConciergeLoomCsv( + "loom_video_url;user_email;space_name\n" + + "https://www.loom.com/share/0123456789abcdef;owner@example.com;" + + JSON.stringify("Sales, Europe") + + "\n", + ); + expect(rows[0]?.spaceName).toBe("Sales, Europe"); + expect(rows[0]?.userEmail).toBe("owner@example.com"); + }); + + it("keeps comma files that have semicolons in their values", () => { + expect( + parseLoomCsvRecords( + "url," + + JSON.stringify("Folder; Archive") + + ",title\n" + + "https://www.loom.com/share/0123456789abcdef,a;b;c,Demo; take 2\n", + ), + ).toEqual([ + ["url", "Folder; Archive", "title"], + ["https://www.loom.com/share/0123456789abcdef", "a;b;c", "Demo; take 2"], + ]); + }); + + it("finds the delimiter after leading blank lines", () => { + const rows = parseConciergeLoomCsv( + "\r\n;;\n \nloom_video_url;user_email\n" + + "https://www.loom.com/share/0123456789abcdef;owner@example.com\n", + ); + expect(rows).toEqual([ + { + rowNumber: 2, + loomUrl: "https://www.loom.com/share/0123456789abcdef", + userEmail: "owner@example.com", + spaceName: undefined, + }, + ]); + }); + + it("treats a single column file as comma separated", () => { + expect( + parseLoomCsvRecords("url\nhttps://www.loom.com/share/a;b\n"), + ).toEqual([["url"], ["https://www.loom.com/share/a;b"]]); + }); +}); + describe("Loom CSV results cells", () => { it("keeps plain values unchanged", () => { expect(formatLoomCsvCell("owner@example.com")).toBe("owner@example.com"); diff --git a/apps/web/lib/loom-csv.ts b/apps/web/lib/loom-csv.ts index d39fcb582e..499a334ca7 100644 --- a/apps/web/lib/loom-csv.ts +++ b/apps/web/lib/loom-csv.ts @@ -2,6 +2,25 @@ import type { LoomCsvImportRow } from "@/actions/loom"; export const MAX_LOOM_CSV_IMPORT_ROWS = 3000; +function detectLoomCsvDelimiter(input: string) { + let commas = 0; + let semicolons = 0; + let hasContent = false; + let inQuotes = false; + for (const char of input) { + if (char === "\u0022") inQuotes = !inQuotes; + else if (inQuotes) hasContent ||= char.trim().length > 0; + else if (char === "\n" || char === "\r") { + if (hasContent) break; + commas = 0; + semicolons = 0; + } else if (char === ",") commas += 1; + else if (char === ";") semicolons += 1; + else hasContent ||= char.trim().length > 0; + } + return semicolons > commas ? ";" : ","; +} + export function parseLoomCsvRecords(text: string) { const records: string[][] = []; let field = ""; @@ -9,6 +28,7 @@ export function parseLoomCsvRecords(text: string) { let inQuotes = false; const input = text.replace(/^\uFEFF/, ""); const quote = "\u0022"; + const delimiter = detectLoomCsvDelimiter(input); for (let index = 0; index < input.length; index += 1) { const char = input.charAt(index); @@ -24,7 +44,7 @@ export function parseLoomCsvRecords(text: string) { continue; } - if (char === "," && !inQuotes) { + if (char === delimiter && !inQuotes) { row.push(field.trim()); field = ""; continue;