// Small RFC 4180 CSV reader / writer (quotes, doubled quotes, CR LF, a UTF-8 BOM). Pure. Used for tester lists, // price sheets and the Play statistics exports. /** One CSV cell, quoted when it holds a quote, comma or newline. */ export function csvCell(s: string): string { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } /** Rows of cells. Empty lines are dropped. */ export function parseCsv(text: string, sep = ','): string[][] { const rows: string[][] = []; let row: string[] = []; let cell = ''; let quoted = false; const s = text.replace(/^/, ''); for (let i = 0; i < s.length; i++) { const ch = s[i]!; if (quoted) { if (ch === '"') { if (s[i + 1] === '"') { cell += '"'; i++; } else quoted = false; } else cell += ch; continue; } if (ch === '"' && cell === '') { quoted = true; continue; } if (ch === sep) { row.push(cell); cell = ''; continue; } if (ch === '\n' || ch === '\r') { if (ch === '\r' && s[i + 1] === '\n') i++; row.push(cell); cell = ''; if (row.some(c => c !== '')) rows.push(row); row = []; continue; } cell += ch; } row.push(cell); if (row.some(c => c !== '')) rows.push(row); return rows; } /** Header row -> objects keyed by the lower-cased, trimmed header (spaces and dashes become "_"). */ export function parseCsvRecords(text: string, sep = ','): Record[] { const [head, ...rest] = parseCsv(text, sep); if (!head) return []; const keys = head.map(h => h.trim().toLowerCase().replace(/[\s-]+/g, '_')); return rest.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()]))); } export const toCsv = (rows: (string | number)[][]): string => rows.map(r => r.map(c => csvCell(String(c))).join(',')).join('\n') + '\n';