/** * CSV utilities — RFC 4180 compliant, no external dependencies. */ /** Escape a field value for CSV (wrap in quotes if needed) */ function escapeField(value: string): string { if ( value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r") ) { return `"${value.replace(/"/g, '""')}"` } return value } /** Convert an array of objects to a CSV string */ export function objectsToCsv( headers: string[], rows: Record[] ): string { const lines: string[] = [] // Header row lines.push(headers.map(escapeField).join(",")) // Data rows for (const row of rows) { const fields = headers.map((h) => escapeField(row[h] ?? "")) lines.push(fields.join(",")) } return lines.join("\n") } /** * Parse a CSV string into an array of objects keyed by header names. * Uses a state-machine parser to correctly handle quoted fields. */ export function csvToObjects(csvText: string): Record[] { const rows = parseCsvRows(csvText) if (rows.length < 1) return [] const headers = rows[0] const result: Record[] = [] for (let i = 1; i < rows.length; i++) { const row = rows[i] // Skip empty rows if (row.length === 1 && row[0] === "") continue const obj: Record = {} for (let j = 0; j < headers.length; j++) { obj[headers[j]] = row[j] ?? "" } result.push(obj) } return result } /** State-machine CSV parser that handles quoted fields correctly */ function parseCsvRows(text: string): string[][] { const rows: string[][] = [] let currentRow: string[] = [] let currentField = "" let inQuotes = false for (let i = 0; i < text.length; i++) { const char = text[i] const nextChar = text[i + 1] if (inQuotes) { if (char === '"') { if (nextChar === '"') { // Escaped quote currentField += '"' i++ // skip next quote } else { // End of quoted field inQuotes = false } } else { currentField += char } } else { if (char === '"' && currentField === "") { // Start of quoted field inQuotes = true } else if (char === ",") { currentRow.push(currentField) currentField = "" } else if (char === "\n") { currentRow.push(currentField) currentField = "" rows.push(currentRow) currentRow = [] } else if (char === "\r") { // Skip carriage return (handle \r\n) if (nextChar === "\n") { i++ // skip \n } currentRow.push(currentField) currentField = "" rows.push(currentRow) currentRow = [] } else { currentField += char } } } // Handle last field/row if (currentField !== "" || currentRow.length > 0) { currentRow.push(currentField) rows.push(currentRow) } return rows }