Files
alla-allaos-fullstack/src/features/setup/server/csv/parser.ts
phaichayon 0b7aeea0a7 task-d.7.4
2026-07-03 08:18:09 +07:00

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
};
}