213 lines
4.8 KiB
TypeScript
213 lines
4.8 KiB
TypeScript
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<string, number>();
|
|
|
|
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<Record<string, string>>((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
|
|
};
|
|
}
|