import type { CsvParsedFile, CsvParsedRow, CsvPreviewError, CsvPreviewWarning, CsvTemplateDefinition } from './types'; function buildIssue( file: string, row: number, column: string, code: string, message: string, suggestedFix: string ): CsvPreviewError { return { file, row, column, code, message, suggestedFix }; } function decodeUtf8(fileName: string, bytes: Uint8Array): { text: string; errors: CsvPreviewError[]; warnings: CsvPreviewWarning[]; } { const errors: CsvPreviewError[] = []; const warnings: CsvPreviewWarning[] = []; if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { warnings.push( buildIssue( fileName, 1, '__file__', 'UTF8_BOM', 'CSV file contains a UTF-8 BOM.', 'Export the CSV as UTF-8 without BOM when possible.' ) ); } try { return { text: new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/^\uFEFF/, ''), errors, warnings }; } catch { errors.push( buildIssue( fileName, 1, '__file__', 'INVALID_ENCODING', 'CSV file is not valid UTF-8.', 'Save the file as UTF-8 CSV and upload it again.' ) ); return { text: '', errors, warnings }; } } function parseCsvText(text: string): string[][] { const rows: string[][] = []; let currentRow: string[] = []; let currentCell = ''; let inQuotes = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; const next = text[index + 1]; if (char === '"') { if (inQuotes && next === '"') { currentCell += '"'; index += 1; } else { inQuotes = !inQuotes; } continue; } if (char === ',' && !inQuotes) { currentRow.push(currentCell); currentCell = ''; continue; } if ((char === '\n' || char === '\r') && !inQuotes) { if (char === '\r' && next === '\n') index += 1; currentRow.push(currentCell); rows.push(currentRow); currentRow = []; currentCell = ''; continue; } currentCell += char; } if (currentCell.length > 0 || currentRow.length > 0) { currentRow.push(currentCell); rows.push(currentRow); } return rows; } export function parseCsvFile(input: { fileName: string; bytes: Uint8Array; template: CsvTemplateDefinition; }): CsvParsedFile { const decoded = decodeUtf8(input.fileName, input.bytes); const errors = [...decoded.errors]; const warnings = [...decoded.warnings]; if (decoded.errors.length > 0) { return { fileName: input.fileName, template: input.template, headers: [], rows: [], errors, warnings }; } const parsedRows = parseCsvText(decoded.text); const [headerRow, ...dataRows] = parsedRows; if (!headerRow) { errors.push( buildIssue( input.fileName, 1, '__header__', 'EMPTY_FILE', 'CSV file is empty.', 'Upload a CSV file with the official template header row.' ) ); } const headers = (headerRow ?? []).map((header) => header.trim()); const rows: CsvParsedRow[] = []; const rowFingerprints = new Map(); dataRows.forEach((raw, index) => { const rowNumber = index + 2; const isEmpty = raw.every((cell) => cell.trim() === ''); if (isEmpty) { warnings.push( buildIssue( input.fileName, rowNumber, '__row__', 'EMPTY_ROW', 'CSV row is empty and will be skipped.', 'Remove empty rows from the file.' ) ); return; } if (raw.length !== headers.length) { errors.push( buildIssue( input.fileName, rowNumber, '__row__', 'INCONSISTENT_COLUMN_COUNT', `Row has ${raw.length} columns but header has ${headers.length}.`, 'Fix quoting or column separators so every row matches the template header.' ) ); } const fingerprint = raw.map((cell) => cell.trim()).join('\u001f'); const firstRow = rowFingerprints.get(fingerprint); if (firstRow) { warnings.push( buildIssue( input.fileName, rowNumber, '__row__', 'DUPLICATE_ROW', `Row duplicates row ${firstRow}.`, 'Remove duplicate rows or confirm that the later row is intentional.' ) ); } else { rowFingerprints.set(fingerprint, rowNumber); } const values = headers.reduce>((accumulator, header, cellIndex) => { accumulator[header] = (raw[cellIndex] ?? '').trim(); return accumulator; }, {}); rows.push({ rowNumber, values, raw }); }); return { fileName: input.fileName, template: input.template, headers, rows, errors, warnings }; }