177 lines
5.5 KiB
TypeScript
177 lines
5.5 KiB
TypeScript
import * as XLSX from 'xlsx';
|
||
import type { ExcelFieldOption, ExcelPreviewResult } from '@mento/shared';
|
||
|
||
function normalizeHeader(raw: string) {
|
||
return raw
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[_-]+/g, '')
|
||
.replace(//g, '')
|
||
.replace(/\s+/g, '')
|
||
.replace(/[()]/g, '');
|
||
}
|
||
|
||
function cellToString(value: unknown): string {
|
||
if (value == null) return '';
|
||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||
return String(value).trim();
|
||
}
|
||
if (value instanceof Date) {
|
||
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
||
}
|
||
return String(value).trim();
|
||
}
|
||
|
||
function resolveFieldKey(
|
||
raw: string,
|
||
aliases: Record<string, string>,
|
||
mapping?: Record<string, string>,
|
||
) {
|
||
if (mapping) {
|
||
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
|
||
}
|
||
const compact = normalizeHeader(raw);
|
||
return aliases[compact] ?? aliases[raw.trim()] ?? '';
|
||
}
|
||
|
||
export type ClientExcelPositionalColumn = { key: string; label: string };
|
||
|
||
export type ClientExcelReadOptions = {
|
||
positionalColumns?: ClientExcelPositionalColumn[];
|
||
detectHeaderless?: (firstRowCells: string[]) => boolean;
|
||
};
|
||
|
||
function readMatrix(fileBuffer: ArrayBuffer) {
|
||
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: true });
|
||
const sheetName = wb.SheetNames[0];
|
||
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
|
||
const sheet = wb.Sheets[sheetName]!;
|
||
return XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
||
header: 1,
|
||
defval: '',
|
||
raw: false,
|
||
}) as unknown[][];
|
||
}
|
||
|
||
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
|
||
export async function previewExcelClient(
|
||
file: File,
|
||
aliases: Record<string, string>,
|
||
fields: ExcelFieldOption[],
|
||
options?: ClientExcelReadOptions,
|
||
): Promise<ExcelPreviewResult> {
|
||
const matrix = readMatrix(await file.arrayBuffer());
|
||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||
const headerless =
|
||
Boolean(options?.positionalColumns?.length) &&
|
||
Boolean(options?.detectHeaderless?.(headerRow));
|
||
|
||
const columns: ExcelPreviewResult['columns'] = [];
|
||
const suggested: Record<string, string> = {};
|
||
const used = new Set<string>();
|
||
|
||
if (headerless && options?.positionalColumns) {
|
||
options.positionalColumns.forEach((colDef, idx) => {
|
||
const samples: string[] = [];
|
||
for (let r = 0; r < Math.min(matrix.length, 5); r += 1) {
|
||
const value = cellToString(matrix[r]?.[idx]);
|
||
if (value) samples.push(value);
|
||
if (samples.length >= 3) break;
|
||
}
|
||
columns.push({ header: colDef.label, index: idx + 1, samples });
|
||
suggested[colDef.label] = colDef.key;
|
||
used.add(colDef.key);
|
||
});
|
||
} else {
|
||
headerRow.forEach((header, idx) => {
|
||
if (!header) return;
|
||
const samples: string[] = [];
|
||
for (let r = 1; r < Math.min(matrix.length, 6); r += 1) {
|
||
const value = cellToString(matrix[r]?.[idx]);
|
||
if (value) samples.push(value);
|
||
if (samples.length >= 3) break;
|
||
}
|
||
columns.push({ header, index: idx + 1, samples });
|
||
const key = resolveFieldKey(header, aliases);
|
||
if (key && !used.has(key)) {
|
||
suggested[header] = key;
|
||
used.add(key);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (!columns.length) throw new Error('ستون عنوان در ردیف اول فایل پیدا نشد');
|
||
|
||
let rowCount = 0;
|
||
const start = headerless ? 0 : 1;
|
||
for (let r = start; r < matrix.length; r += 1) {
|
||
const row = matrix[r] ?? [];
|
||
if (row.some((c) => cellToString(c))) rowCount += 1;
|
||
}
|
||
|
||
const required = fields.filter((f) => f.required).map((f) => f.key);
|
||
const mapped = new Set(Object.values(suggested));
|
||
const missingRequired = required.some((key) => !mapped.has(key));
|
||
const unrecognized = columns.some((col) => !suggested[col.header]);
|
||
|
||
return {
|
||
columns,
|
||
fields,
|
||
suggested,
|
||
needsMapping: headerless
|
||
? false
|
||
: unrecognized || missingRequired || !mapped.size,
|
||
rowCount,
|
||
largeFile: rowCount >= 80,
|
||
};
|
||
}
|
||
|
||
export async function readMappedRowsClient(
|
||
file: File,
|
||
aliases: Record<string, string>,
|
||
mapping?: Record<string, string>,
|
||
options?: ClientExcelReadOptions,
|
||
): Promise<Record<string, string>[]> {
|
||
const matrix = readMatrix(await file.arrayBuffer());
|
||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||
const headerless =
|
||
!mapping &&
|
||
Boolean(options?.positionalColumns?.length) &&
|
||
Boolean(options?.detectHeaderless?.(headerRow));
|
||
|
||
const colMap = new Map<number, string>();
|
||
let dataStart = 1;
|
||
|
||
if (headerless && options?.positionalColumns) {
|
||
options.positionalColumns.forEach((colDef, idx) => {
|
||
colMap.set(idx, colDef.key);
|
||
});
|
||
dataStart = 0;
|
||
} else {
|
||
headerRow.forEach((header, idx) => {
|
||
if (!header) return;
|
||
const key = resolveFieldKey(header, aliases, mapping);
|
||
if (key) colMap.set(idx, key);
|
||
});
|
||
}
|
||
|
||
if (!colMap.size) {
|
||
throw new Error('ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.');
|
||
}
|
||
|
||
const rows: Record<string, string>[] = [];
|
||
for (let r = dataStart; r < matrix.length; r += 1) {
|
||
const line = matrix[r] ?? [];
|
||
const item: Record<string, string> = {};
|
||
let any = false;
|
||
for (const [idx, key] of colMap) {
|
||
const value = cellToString(line[idx]);
|
||
if (value) any = true;
|
||
item[key] = value;
|
||
}
|
||
if (any) rows.push(item);
|
||
}
|
||
if (!rows.length) throw new Error('هیچ ردیف دادهای در فایل نیست');
|
||
return rows;
|
||
}
|