export type CsvDelimiter = "," | ";" | "\t"; function detectDelimiter(text: string): CsvDelimiter { const counts: Record = { ",": 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(""); }