diff --git a/src/csv.js b/src/csv.js index 5d159d8..6093983 100644 --- a/src/csv.js +++ b/src/csv.js @@ -1,7 +1,7 @@ import { LedgerpetError } from "./errors.js"; export function parseCsv(text, source = "csv") { - const lines = splitCsvRecords(text.replace(/^\uFEFF/, "").trim()).filter(Boolean); + const lines = splitCsvRecords(text.replace(/^\uFEFF/, "")).filter((line) => line !== ""); if (lines.length === 0) return []; const headers = splitCsvLine(lines[0]); return lines.slice(1).map((line, index) => { @@ -26,15 +26,15 @@ function splitCsvRecords(text) { } else if (char === '"') { inQuotes = !inQuotes; record += char; - } else if ((char === "\n" || (char === "\r" && next === "\n")) && !inQuotes) { + } else if ((char === "\r" || char === "\n") && !inQuotes) { records.push(record); record = ""; - if (char === "\r") i += 1; + if (char === "\r" && next === "\n") i += 1; } else { record += char; } } - if (record) records.push(record); + records.push(record); return records; } @@ -63,12 +63,15 @@ function splitCsvLine(line) { cell += char; } } - if (inQuotes) throw new LedgerpetError(`Unclosed quote in CSV line: ${line}`, "CSV_QUOTE"); + if (inQuotes) { + const displayLine = line.replace(/(?:\r\n|\r|\n)$/, ""); + throw new LedgerpetError(`Unclosed quote in CSV line: ${displayLine}`, "CSV_QUOTE"); + } cells.push(cell); return cells; } function quoteCsv(value) { const text = String(value ?? ""); - return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; } diff --git a/tests/csv.test.js b/tests/csv.test.js index 277cdec..9d60f6b 100644 --- a/tests/csv.test.js +++ b/tests/csv.test.js @@ -37,3 +37,32 @@ test("parseCsv reports unterminated multiline records", () => { test("toCsv quotes unsafe values", () => { assert.equal(toCsv([{ id: 1, name: "Acme, Inc" }]), 'id,name\n1,"Acme, Inc"\n'); }); + +test("parseCsv accepts CR-only record separators", () => { + assert.deepEqual(parseCsv("id,note\r1,value\r2,next", "classic-mac.csv"), [ + { id: "1", note: "value" }, + { id: "2", note: "next" }, + ]); +}); + +test("parseCsv preserves unquoted field edge whitespace", () => { + assert.deepEqual(parseCsv("id,note\n 1 , keep me \n", "spaces.csv"), [ + { id: " 1 ", note: " keep me " }, + ]); +}); + +test("toCsv round trips unquoted field edge whitespace", () => { + const rows = [{ id: " 1", note: "tail " }]; + assert.deepEqual(parseCsv(toCsv(rows)), rows); +}); + +test("parseCsv preserves CR and LF inside quoted multiline fields", () => { + assert.deepEqual(parseCsv('id,note\r1,"first\rsecond\nthird\r\nfourth"\r', "multiline.csv"), [ + { id: "1", note: "first\rsecond\nthird\r\nfourth" }, + ]); +}); + +test("toCsv quotes and round trips fields containing CR", () => { + const rows = [{ id: "1", note: "first\rsecond" }]; + assert.deepEqual(parseCsv(toCsv(rows)), rows); +});