storebound/cli/lib/csv.ts

45 lines
1.8 KiB
TypeScript

// 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<string, string>[] {
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';