From e0085ecb6fe7bc37e5053acfb201dfd2b4b811de Mon Sep 17 00:00:00 2001 From: ztimson Date: Mon, 24 Mar 2025 21:28:51 -0400 Subject: [PATCH] Better CSV handling --- package.json | 2 +- src/csv.ts | 63 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 098ed24..6815e5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.23.15", + "version": "0.23.16", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/csv.ts b/src/csv.ts index 3e38218..dfc85b8 100644 --- a/src/csv.ts +++ b/src/csv.ts @@ -1,27 +1,40 @@ +import {makeArray} from './array.ts'; import {ASet} from './aset.ts'; import {dotNotation, flattenObj, JSONSanitize} from './objects.ts'; import {LETTER_LIST} from './string.ts'; +/** + * Parse a CSV string into an array of objects + * + * @param csv String with CSV + * @param hasHeaders First line of CSV contains headers + * @return {T[]} Array of parsed objects + */ export function fromCsv(csv: string, hasHeaders=true): T[] { - const row = csv.split('\n'); - let headers: any = hasHeaders ? row.splice(0, 1)[0] : null; - if(headers) headers = headers.match(/(?:[^,"']+|"[^"]*"|'[^']*')+/g); - return row.map(r => { - function parseLine(line: string): (string | null)[] { - const parts = line.split(','), columns: string[] = []; - let quoted = false; - for(const p of parts) { - if(quoted) columns[columns.length - 1] = columns.at(-1) + ',' + p; - else columns.push(p); - if(/[^"]"$/g.test(p)) { - quoted = false; - } else if(/^"[^"]/g.test(p)) { - quoted = true; - } - } - return columns; + function parseLine(line: string): (string | null)[] { + const columns: string[] = []; + let current = '', inQuotes = false; + for(let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + if (char === '"') { + if (inQuotes && nextChar === '"') { + current += '"'; + i++; + } else inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + columns.push(current); + current = ''; + } else current += char; } + columns.push(current); + return columns.map(col => col.replace(/^"|"$/g, '').replace(/""/g, '"')); + } + const row = csv.split(/\r?\n/); + let headers: any = hasHeaders ? row.splice(0, 1)[0] : null; + if(headers) headers = headers.match(/(?:[^,"']+|"(?:[^"]|"")*"|'(?:[^']|'')*')+/g); + return row.map(r => { const props = parseLine(r); const h = headers || (Array(props.length).fill(null).map((r, i) => { let letter = ''; @@ -34,23 +47,27 @@ export function fromCsv(csv: string, hasHeaders=true): T[] { dotNotation(acc, h, props[i]); return acc; }, {}); - }) + }); } /** - * Convert an object to a CSV string + * Convert an array of objects to a CSV string * * @param {any[]} target Array of objects to create CSV from * @param {boolean} flatten Should nested object be flattened or treated as values * @return {string} CSV string */ -export function toCsv(target: any[], flatten=true) { - const headers = new ASet(target.reduce((acc, row) => [...acc, ...Object.keys(flatten ? flattenObj(row) : row)], [])); +export function toCsv(target: any, flatten=true) { + const t = makeArray(target); + const headers = new ASet(t.reduce((acc, row) => [...acc, ...Object.keys(flatten ? flattenObj(row) : row)], [])); return [ headers.join(','), - ...target.map(row => headers.map((h: string) => { + ...t.map(row => headers.map((h: string) => { const value = dotNotation(row, h); - return (typeof value == 'object' && value != null) ? '"' + JSONSanitize(value).replaceAll('"', '""') + '"' : value; + if(value == null) return ''; + if(typeof value == 'object') return `"${JSONSanitize(value).replaceAll('`', '""')}"`; + if(typeof value == 'string' && /[\n"]/g.test(value)) return `"${value.replaceAll('"', '""')}"`; + return value; }).join(',')) ].join('\n'); }