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
15 changes: 9 additions & 6 deletions src/csv.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
29 changes: 29 additions & 0 deletions tests/csv.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});