task-d.7.4
This commit is contained in:
212
src/features/setup/server/csv/parser.ts
Normal file
212
src/features/setup/server/csv/parser.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user