From b391456d22e3f5761898b806db5f963d6650b5fd Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:48:34 +0000 Subject: [PATCH 1/2] fix(web): accept semicolon separated Loom CSVs --- apps/web/__tests__/unit/loom-csv.test.ts | 41 ++++++++++++++++++++++++ apps/web/lib/loom-csv.ts | 17 +++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts index 0c50abfc8fd..beb8090e160 100644 --- a/apps/web/__tests__/unit/loom-csv.test.ts +++ b/apps/web/__tests__/unit/loom-csv.test.ts @@ -33,6 +33,47 @@ 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;"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,"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("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 d39fcb582e6..238734ac826 100644 --- a/apps/web/lib/loom-csv.ts +++ b/apps/web/lib/loom-csv.ts @@ -2,6 +2,20 @@ 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 inQuotes = false; + for (const char of input) { + if (char === "\u0022") inQuotes = !inQuotes; + else if (inQuotes) continue; + else if (char === "\n" || char === "\r") break; + else if (char === ",") commas += 1; + else if (char === ";") semicolons += 1; + } + return semicolons > commas ? ";" : ","; +} + export function parseLoomCsvRecords(text: string) { const records: string[][] = []; let field = ""; @@ -9,6 +23,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 +39,7 @@ export function parseLoomCsvRecords(text: string) { continue; } - if (char === "," && !inQuotes) { + if (char === delimiter && !inQuotes) { row.push(field.trim()); field = ""; continue; From 6f4ececdd6d59548c6dc175ab93fb4279d2a8d20 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:54:07 +0000 Subject: [PATCH 2/2] fix(web): skip blank lines when detecting the Loom CSV delimiter --- apps/web/__tests__/unit/loom-csv.test.ts | 23 +++++++++++++++++++++-- apps/web/lib/loom-csv.ts | 11 ++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts index beb8090e160..f78f011f093 100644 --- a/apps/web/__tests__/unit/loom-csv.test.ts +++ b/apps/web/__tests__/unit/loom-csv.test.ts @@ -49,7 +49,9 @@ describe("Loom CSV delimiters", () => { 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;"Sales, Europe"\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"); @@ -58,7 +60,9 @@ describe("Loom CSV delimiters", () => { it("keeps comma files that have semicolons in their values", () => { expect( parseLoomCsvRecords( - 'url,"Folder; Archive",title\n' + + "url," + + JSON.stringify("Folder; Archive") + + ",title\n" + "https://www.loom.com/share/0123456789abcdef,a;b;c,Demo; take 2\n", ), ).toEqual([ @@ -67,6 +71,21 @@ describe("Loom CSV delimiters", () => { ]); }); + 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"), diff --git a/apps/web/lib/loom-csv.ts b/apps/web/lib/loom-csv.ts index 238734ac826..499a334ca71 100644 --- a/apps/web/lib/loom-csv.ts +++ b/apps/web/lib/loom-csv.ts @@ -5,13 +5,18 @@ 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) continue; - else if (char === "\n" || char === "\r") break; - else if (char === ",") commas += 1; + 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 ? ";" : ","; }