feat(plugins): csv and json import

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 15:48:59 +01:00
co-authored by Claude Sonnet 5.5
parent 06a59e1b5c
commit a540c7d34d
7 changed files with 429 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
export type CsvDelimiter = "," | ";" | "\t";
function detectDelimiter(text: string): CsvDelimiter {
const counts: Record<CsvDelimiter, number> = { ",": 0, ";": 0, "\t": 0 };
let inQuotes = false;
for (const ch of text) {
if (ch === '"') inQuotes = !inQuotes;
else if (!inQuotes) {
if (ch === "\n" || ch === "\r") break;
if (ch === "," || ch === ";" || ch === "\t") counts[ch]++;
}
}
let best: CsvDelimiter = ",";
for (const d of [";", "\t"] as const) if (counts[d] > counts[best]) best = d;
return best;
}
/** RFC 4180 parser. Strips a BOM, auto-detects the delimiter from the header line, skips blank lines. */
export function parseCsv(text: string, opts: { delimiter?: CsvDelimiter } = {}): string[][] {
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
const delim = opts.delimiter ?? detectDelimiter(text);
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false; // current field was quoted
let inQuotes = false;
let pending = false; // something has been read for the current row
const endField = () => {
row.push(field);
field = "";
};
const endRow = () => {
const blank = row.length === 0 && field === "" && !quoted;
if (!blank) {
endField();
rows.push(row);
}
row = [];
field = "";
quoted = false;
pending = false;
};
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else inQuotes = false;
} else field += ch;
} else if (ch === '"' && field === "" && !quoted) {
inQuotes = true;
quoted = true;
pending = true;
} else if (ch === delim) {
endField();
quoted = false;
pending = true;
} else if (ch === "\r" || ch === "\n") {
if (ch === "\r" && text[i + 1] === "\n") i++;
endRow();
} else {
field += ch;
pending = true;
}
}
if (pending || field !== "" || row.length > 0) endRow();
return rows;
}
function quoteCell(s: string, single: boolean): string {
if (s === "" ? single : /[",;\t\r\n]/.test(s) || s !== s.trim()) return `"${s.replaceAll('"', '""')}"`;
return s;
}
/** Serialises with `,` and `\r\n`, quoting only where needed. */
export function toCsv(rows: string[][]): string {
return rows.map((r) => r.map((c) => quoteCell(c, r.length === 1)).join(",") + "\r\n").join("");
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { parseCsv, toCsv } from "../src/csv.ts";
describe("csv codec", () => {
test("round-trips awkward cells", () => {
const x = [["a", "b,c", 'q"uote', "line\nbreak", "cr\r\nlf", "", "🐉"], ["", "", "", "", "", "", ""], ["only"]];
expect(parseCsv(toCsv(x))).toEqual(x);
});
test("toCsv uses CRLF and quotes as needed", () => {
expect(toCsv([["a", "b c"], ["x,y", 'z"']])).toBe('a,b c\r\n"x,y","z"""\r\n');
});
test("strips BOM and detects semicolon / tab", () => {
expect(parseCsv("a;b\n1;2\n")).toEqual([["a", "b"], ["1", "2"]]);
expect(parseCsv("a\tb\n1\t2")).toEqual([["a", "b"], ["1", "2"]]);
});
test("explicit delimiter wins; delimiters inside quoted header are ignored", () => {
expect(parseCsv('"a;b;c",d\n1,2', {})).toEqual([["a;b;c", "d"], ["1", "2"]]);
expect(parseCsv("a,b;c\n1,2;3", { delimiter: ";" })).toEqual([["a,b", "c"], ["1,2", "3"]]);
});
test("handles CRLF, blank trailing lines, and empty input", () => {
expect(parseCsv("a,b\r\n1,2\r\n\r\n")).toEqual([["a", "b"], ["1", "2"]]);
expect(parseCsv("")).toEqual([]);
});
});