whisper-money/resources/js/lib/file-parser.ts

370 lines
12 KiB
TypeScript

import * as XLSX from 'xlsx';
import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import';
function detectHeaderRow(columns: unknown[][]): number {
if (!columns || columns.length === 0) {
return 0;
}
const firstRowWithValue = columns.map((column) =>
column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1)
);
const percentages = [0.95, 0.75];
for (const minPercentage of percentages) {
const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b);
for (const rowNumber of uniqueRows) {
if (rowNumber === -1) continue;
const columnsWithValues = columns.filter((column) => {
return column[rowNumber] !== undefined &&
column[rowNumber] !== null &&
String(column[rowNumber]).length > 1;
}).length;
if (columnsWithValues / columns.length >= minPercentage) {
return rowNumber;
}
}
}
return 0;
}
export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
if (!data) {
reject(new Error('Failed to read file'));
return;
}
const workbook = XLSX.read(data, { type: 'binary' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][];
if (jsonData.length === 0) {
reject(new Error('File is empty'));
return;
}
const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0));
const columns: unknown[][] = [];
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
const columnData = jsonData.map(row =>
Array.isArray(row) ? row[colIndex] : undefined
);
columns.push(columnData);
}
const headerRowIndex = detectHeaderRow(columns);
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
const headers = columns.map((column, index) => {
const headerValue = column[headerRowIndex];
const headerStr = String(headerValue || '').trim();
if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) {
return headerStr;
}
return letters[index] || `Column ${index + 1}`;
});
const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][];
const parsedData: ParsedRow[] = dataRows
.filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== ''))
.map(row => {
const obj: ParsedRow = {};
headers.forEach((header, index) => {
if (header) {
const value = row[index];
obj[header] = (value === null || value === undefined) ? null : value as string | number;
}
});
return obj;
});
resolve({ headers, data: parsedData, columns, headerRowIndex });
} catch {
reject(new Error('Failed to parse file'));
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsBinaryString(file);
});
}
export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null {
if (!data || data.length === 0 || !dateColumnName) {
return null;
}
const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear];
const sampleSize = Math.min(10, data.length);
const scores: Record<DateFormat, number> = {
[DateFormat.YearMonthDay]: 0,
[DateFormat.DayMonthYear]: 0,
[DateFormat.MonthDayYear]: 0,
};
for (let i = 0; i < sampleSize; i++) {
const dateValue = data[i][dateColumnName];
if (!dateValue) continue;
for (const format of formats) {
const parsedDate = parseDate(dateValue as string | number, format);
if (parsedDate) {
scores[format]++;
}
}
}
const maxScore = Math.max(...Object.values(scores));
if (maxScore === 0) {
return null;
}
const bestFormat = formats.find(format => scores[format] === maxScore);
if (maxScore >= sampleSize * 0.8) {
return bestFormat || null;
}
return null;
}
export function autoDetectColumns(headers: string[]): ColumnMapping {
const mapping: ColumnMapping = {
transaction_date: null,
description: null,
amount: null,
};
if (!headers || headers.length === 0) {
return mapping;
}
const lowerHeaders = headers.map(h => {
if (h === null || h === undefined) {
return '';
}
return String(h).toLowerCase();
});
const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor'];
const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción'];
const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad'];
for (let i = 0; i < lowerHeaders.length; i++) {
const header = lowerHeaders[i];
const originalHeader = headers[i];
if (!header || typeof header !== 'string') {
continue;
}
if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) {
mapping.transaction_date = originalHeader;
}
if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) {
mapping.description = originalHeader;
}
if (!mapping.amount && amountPatterns.some(p => header.includes(p))) {
mapping.amount = originalHeader;
}
}
return mapping;
}
export function parseDate(dateStr: string | number, format: DateFormat): Date | null {
if (!dateStr) {
return null;
}
if (typeof dateStr === 'number') {
const excelDate = XLSX.SSF.parse_date_code(dateStr);
if (excelDate) {
return new Date(excelDate.y, excelDate.m - 1, excelDate.d);
}
}
let str = String(dateStr).trim();
str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, '');
let year: number | undefined, month: number | undefined, day: number | undefined;
if (str.length === 5) {
const dateRegex = /^(\d{1,2})-(\d{1,2})$/;
const dateArray = dateRegex.exec(str);
if (dateArray) {
month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]);
day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]);
}
} else {
const parts = str.split('-').filter(p => p.length > 0);
if (parts.length === 3) {
switch (format) {
case DateFormat.YearMonthDay:
[year, month, day] = parts.map(Number);
break;
case DateFormat.MonthDayYear:
[month, day, year] = parts.map(Number);
break;
case DateFormat.DayMonthYear:
[day, month, year] = parts.map(Number);
break;
}
} else if (parts.length === 2) {
month = Number(parts[format === DateFormat.DayMonthYear ? 1 : 0]);
day = Number(parts[format === DateFormat.DayMonthYear ? 0 : 1]);
}
}
if (year === undefined) {
year = new Date().getFullYear();
}
if (year < 100) {
year += year < 50 ? 2000 : 1900;
}
if (year === undefined || month === undefined || day === undefined) {
return null;
}
const date = new Date(year, month - 1, day);
if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return date;
}
export function parseAmount(amountStr: string | number): number | null {
if (typeof amountStr === 'number') {
return amountStr;
}
if (!amountStr) {
return null;
}
let str = String(amountStr).trim();
const dotPos = str.lastIndexOf('.');
const commaPos = str.lastIndexOf(',');
const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos :
commaPos > dotPos && commaPos !== -1 ? commaPos :
-1;
if (decimalSep !== -1) {
const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, '');
const decimalPart = str.substring(decimalSep + 1);
str = integerPart + '.' + decimalPart;
} else {
str = str.replace(/[^\d]/g, '');
}
const amount = parseFloat(str);
if (isNaN(amount)) {
return null;
}
return amount;
}
export function validateTransaction(
row: ParsedRow,
mapping: ColumnMapping,
dateFormat: DateFormat
): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!mapping.transaction_date || !row[mapping.transaction_date]) {
errors.push('Missing transaction date');
} else {
const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat);
if (!date) {
errors.push('Invalid date format');
}
}
if (!mapping.description || !row[mapping.description]) {
errors.push('Missing description');
}
if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) {
errors.push('Missing amount');
} else {
const amount = parseAmount(row[mapping.amount] as string | number);
if (amount === null) {
errors.push('Invalid amount format');
}
}
return {
isValid: errors.length === 0,
errors,
};
}
export function convertRowsToTransactions(
rows: ParsedRow[],
mapping: ColumnMapping,
dateFormat: DateFormat
): ParsedTransaction[] {
const results: ParsedTransaction[] = [];
for (const row of rows) {
const validation = validateTransaction(row, mapping, dateFormat);
if (!validation.isValid) {
continue;
}
const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat);
const amount = parseAmount(row[mapping.amount!] as string | number);
const description = String(row[mapping.description!] || '').trim();
if (!date || amount === null || !description) {
continue;
}
const formattedDate = date.toISOString().split('T')[0];
results.push({
transaction_date: formattedDate,
description,
amount,
validationErrors: [],
});
}
return results;
}