/** Independently developed CSV utilities. No network, storage, or dependencies. */ export const CSV_LIMITS = Object.freeze({ bytes: 2 * 1024 * 1024, rows: 5000, columns: 50 }); export class CsvError extends Error { constructor(message) { super(message); this.name = 'CsvError'; } } export function parseCSV(source, limits = CSV_LIMITS) { if (typeof source !== 'string') throw new CsvError('Choose or paste a text CSV file.'); if (new globalThis.TextEncoder().encode(source).byteLength > limits.bytes) throw new CsvError('This CSV exceeds the 2 MiB limit. Use a smaller extract.'); const text = source.replace(/^\uFEFF/, ''); if (!text.length) throw new CsvError('Add a CSV with a header row and at least one column.'); const records = []; let row = []; let field = ''; let state = 'start'; const pushField = () => { row.push(field); field = ''; state = 'start'; if (row.length > limits.columns) throw new CsvError(`This CSV exceeds the ${limits.columns}-column limit.`); }; const pushRow = () => { pushField(); if (records.length && row.length !== records[0].length) throw new CsvError( `Record ${records.length + 1} has ${row.length} fields; expected ${records[0].length}. Fix this uneven row before loading.`, ); records.push(row); row = []; if (records.length > limits.rows + 1) throw new CsvError( `This CSV exceeds the ${limits.rows.toLocaleString('en-US')}-data-row limit.`, ); }; for (let index = 0; index < text.length; index++) { const character = text[index]; if (state === 'quoted') { if (character === '"') { if (text[index + 1] === '"') { field += '"'; index++; } else state = 'afterQuote'; } else field += character; continue; } if (character === ',') { pushField(); continue; } if (character === '\r' || character === '\n') { pushRow(); if (character === '\r' && text[index + 1] === '\n') index++; continue; } if (state === 'afterQuote') throw new CsvError( `Unexpected character after a closing quote in record ${records.length + 1}. A quoted field must end at a comma or line break.`, ); if (character === '"') { if (state !== 'start') throw new CsvError( `Unexpected quote in record ${records.length + 1}. Quote the entire field and double any quotes inside it.`, ); state = 'quoted'; } else { field += character; state = 'unquoted'; } } if (state === 'quoted') throw new CsvError(`Unclosed quoted field in record ${records.length + 1}.`); if (row.length || field.length || state !== 'start') pushRow(); if (!records.length || records[0].every((value) => !value.trim())) throw new CsvError('The first row needs at least one nonblank column name.'); return { headers: records[0], rows: records.slice(1) }; } const isMissing = (value) => value.trim() === ''; const numberPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; function numericValue(value) { const text = value.trim(); if (!text || !numberPattern.test(text)) return null; const number = Number(text); return Number.isFinite(number) ? number : null; } export function profileCSV(dataset) { const seen = new Set(); let duplicateRows = 0; for (const row of dataset.rows) { const key = JSON.stringify(row); if (seen.has(key)) duplicateRows++; seen.add(key); } const columns = dataset.headers.map((header, index) => { let missing = 0; let numericCount = 0; let textCount = 0; let min = null; let max = null; let mean = null; for (const row of dataset.rows) { const value = row[index]; if (isMissing(value)) { missing++; continue; } const number = numericValue(value); if (number === null) { textCount++; continue; } numericCount++; min = min === null ? number : Math.min(min, number); max = max === null ? number : Math.max(max, number); mean = mean === null ? number : mean * ((numericCount - 1) / numericCount) + number / numericCount; } return { name: header.trim() || `Column ${index + 1}`, index, missing, numericCount, textCount, min, max, mean, completeness: dataset.rows.length ? (100 * (dataset.rows.length - missing)) / dataset.rows.length : null, type: numericCount && textCount ? 'Mixed' : numericCount ? 'Numeric-looking' : textCount ? 'Text' : 'Empty', }; }); return { rowCount: dataset.rows.length, columnCount: dataset.headers.length, missingCells: columns.reduce((sum, column) => sum + column.missing, 0), duplicateRows, columns, }; } export function cleanCSV(dataset, options = {}) { const seen = new Set(); const changes = { trimmedCells: 0, emptyRowsRemoved: 0, duplicatesRemoved: 0 }; const rows = []; for (const original of dataset.rows) { const row = original.map((value) => { const next = options.trimWhitespace ? value.trim() : value; if (next !== value) changes.trimmedCells++; return next; }); if (options.dropEmptyRows && row.every(isMissing)) { changes.emptyRowsRemoved++; continue; } const key = JSON.stringify(row); if (options.removeDuplicates && seen.has(key)) { changes.duplicatesRemoved++; continue; } seen.add(key); rows.push(row); } return { dataset: { headers: [...dataset.headers], rows }, changes }; } export function neutralizeSpreadsheetFormula(value) { // Prefix inside the quoted CSV field. Quoting alone does not stop a // spreadsheet evaluating a formula. Headers need the same protection. // eslint-disable-next-line no-control-regex -- Control prefixes can hide a spreadsheet formula marker. return /^[\s\u0000-\u001f\u007f\uFEFF]*[=+\-@]/u.test(value) || /^[\t\r\n]/.test(value) ? `'${value}` : value; } export function serializeCSV(dataset) { let neutralizedCells = 0; const records = [dataset.headers, ...dataset.rows]; const csv = records .map((row) => row .map((value) => { const safe = neutralizeSpreadsheetFormula(value); if (safe !== value) neutralizedCells++; return `"${safe.replaceAll('"', '""')}"`; }) .join(','), ) .join('\r\n') + '\r\n'; return { csv, neutralizedCells }; }