import type { TypeAndName, TypeAndDisplayName } from './base.ts'; import { KnownAmountFormat } from './numeral.ts'; import { KnownDateTimeFormat } from './datetime.ts'; import { KnownDateTimezoneFormat } from './timezone.ts'; import { TransactionType } from './transaction.ts'; export class ImportTransactionColumnType implements TypeAndName { private static readonly allInstances: ImportTransactionColumnType[] = []; public static readonly TransactionTime = new ImportTransactionColumnType(1, 'Transaction Time'); public static readonly TransactionTimezone = new ImportTransactionColumnType(2, 'Transaction Timezone'); public static readonly TransactionType = new ImportTransactionColumnType(3, 'Transaction Type'); public static readonly Category = new ImportTransactionColumnType(4, 'Category'); public static readonly SubCategory = new ImportTransactionColumnType(5, 'Secondary Category'); public static readonly AccountName = new ImportTransactionColumnType(6, 'Account Name'); public static readonly AccountCurrency = new ImportTransactionColumnType(7, 'Currency'); public static readonly Amount = new ImportTransactionColumnType(8, 'Amount'); public static readonly RelatedAccountName = new ImportTransactionColumnType(9, 'Transfer In Account Name'); public static readonly RelatedAccountCurrency = new ImportTransactionColumnType(10, 'Transfer In Currency'); public static readonly RelatedAmount = new ImportTransactionColumnType(11, 'Transfer In Amount'); public static readonly GeographicLocation = new ImportTransactionColumnType(12, 'Geographic Location'); public static readonly Tags = new ImportTransactionColumnType(13, 'Tags'); public static readonly Description = new ImportTransactionColumnType(14, 'Description'); public readonly type: number; public readonly name: string; private constructor(type: number, name: string) { this.type = type; this.name = name; ImportTransactionColumnType.allInstances.push(this); } public static values(): ImportTransactionColumnType[] { return ImportTransactionColumnType.allInstances; } } export class ImportTransactionDataMapping { private static readonly JSON_ROOT_FIELD = 'ezBookkeepingImportTransactionDataMapping'; private static readonly DEFAULT_INCLUDE_HEADER = true; private static readonly DEFAULT_TIME_FORMAT = ''; private static readonly DEFAULT_TIMEZONE_FORMAT = ''; private static readonly DEFAULT_AMOUNT_FORMAT = ''; private static readonly DEFAULT_GEO_LOCATION_SEPARATOR = ' '; private static readonly DEFAULT_GEO_LOCATION_ORDER = 'lonlat'; private static readonly DEFAULT_TAG_SEPARATOR = ';'; public includeHeader: boolean; public dataColumnMapping: Record; public transactionTypeMapping: Record; public timeFormat: string; public timezoneFormat: string; public amountFormat: string; public geoLocationSeparator: string; public geoLocationOrder: string; public tagSeparator: string; private constructor(includeHeader: boolean, dataColumnMapping: Record, transactionTypeMapping: Record, timeFormat: string, timezoneFormat: string, amountFormat: string, geoLocationSeparator: string, geoLocationOrder: string, tagSeparator: string) { this.includeHeader = includeHeader; this.dataColumnMapping = dataColumnMapping; this.transactionTypeMapping = transactionTypeMapping; this.timeFormat = timeFormat; this.timezoneFormat = timezoneFormat; this.amountFormat = amountFormat; this.geoLocationSeparator = geoLocationSeparator; this.geoLocationOrder = geoLocationOrder; this.tagSeparator = tagSeparator; } public isColumnMappingSet(column: ImportTransactionColumnType | TypeAndDisplayName): boolean { return this.dataColumnMapping.hasOwnProperty(column.type) && typeof(this.dataColumnMapping[column.type]) === 'number'; } public toggleIncludeHeader(): void { this.includeHeader = !this.includeHeader; } public toggleDataMappingColumn(columnIndex: number, columnType: number): void { if (this.dataColumnMapping[columnType] === columnIndex) { delete this.dataColumnMapping[columnType]; } else { this.dataColumnMapping[columnType] = columnIndex; } for (const otherColumnType in this.dataColumnMapping) { if (otherColumnType !== columnType.toString() && this.dataColumnMapping[otherColumnType] === columnIndex) { delete this.dataColumnMapping[otherColumnType]; } } } public setGeoLocationFormat(separator: string, order: string): void { this.geoLocationSeparator = separator; this.geoLocationOrder = order; } public formatGeoLocation(latitude: string, longitude: string): string { if (this.geoLocationOrder === 'latlon') { return `${latitude}${this.geoLocationSeparator}${longitude}`; } else { return `${longitude}${this.geoLocationSeparator}${latitude}`; } } public parseFileAllTransactionTypes(fileData: string[][] | undefined): string[] { if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.TransactionType)) { return []; } const allTypeMap: Record = {}; const allTypes: string[] = []; const typeColumnIndex = this.dataColumnMapping[ImportTransactionColumnType.TransactionType.type]; const startIndex = this.includeHeader ? 1 : 0; for (let i = startIndex; i < fileData.length; i++) { if (fileData[i].length <= typeColumnIndex) { continue; } const type = fileData[i][typeColumnIndex]; if (type && !allTypeMap[type]) { allTypes.push(type); allTypeMap[type] = true; } } return allTypes; } public parseFileValidMappedTransactionTypes(fileData: string[][] | undefined): Record { if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.TransactionType)) { return {}; } const result: Record = {}; if (!this.transactionTypeMapping) { return result; } for (const name in this.transactionTypeMapping) { if (!Object.prototype.hasOwnProperty.call(this.transactionTypeMapping, name)) { continue; } const type = this.transactionTypeMapping[name]; if (TransactionType.ModifyBalance <= type && type <= TransactionType.Transfer) { result[name] = type; } } return result; } public parseFileAutoDetectedTimeFormat(fileData: string[][] | undefined): string | undefined { if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)) { return undefined; } const allDateTimes: string[] = []; const dateTimeColumnIndex = this.dataColumnMapping[ImportTransactionColumnType.TransactionTime.type]; const startIndex = this.includeHeader ? 1 : 0; for (let i = startIndex; i < fileData.length; i++) { if (fileData[i].length <= dateTimeColumnIndex) { continue; } const dateTime = fileData[i][dateTimeColumnIndex]; if (dateTime) { allDateTimes.push(dateTime); } } const detectedFormats = KnownDateTimeFormat.detectMulti(allDateTimes); if (!detectedFormats || !detectedFormats.length || detectedFormats.length > 1) { return undefined; } return detectedFormats[0].format; } public parseFileAutoDetectedTimezoneFormat(fileData: string[][] | undefined): string | undefined { if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)) { return undefined; } const allTimezones: string[] = []; const timezoneColumnIndex = this.dataColumnMapping[ImportTransactionColumnType.TransactionTimezone.type]; const startIndex = this.includeHeader ? 1 : 0; for (let i = startIndex; i < fileData.length; i++) { if (fileData[i].length <= timezoneColumnIndex) { continue; } const timezone = fileData[i][timezoneColumnIndex]; if (timezone) { allTimezones.push(timezone); } } const detectedFormats = KnownDateTimezoneFormat.detectMulti(allTimezones); if (!detectedFormats || !detectedFormats.length || detectedFormats.length > 1) { return undefined; } return detectedFormats[0].value; } public parseFileAutoDetectedAmountFormat(fileData: string[][] | undefined): string | undefined { if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.Amount)) { return undefined; } const allAmounts: string[] = []; const amountColumnIndex = this.dataColumnMapping[ImportTransactionColumnType.Amount.type]; const startIndex = this.includeHeader ? 1 : 0; for (let i = startIndex; i < fileData.length; i++) { if (fileData[i].length <= amountColumnIndex) { continue; } const amount = fileData[i][amountColumnIndex]; if (amount) { allAmounts.push(amount); } } const detectedFormats = KnownAmountFormat.detectMulti(allAmounts); if (!detectedFormats || !detectedFormats.length) { return undefined; } return detectedFormats[0].type; } public reset(): void { this.includeHeader = ImportTransactionDataMapping.DEFAULT_INCLUDE_HEADER; this.dataColumnMapping = {}; this.transactionTypeMapping = {}; this.timeFormat = ImportTransactionDataMapping.DEFAULT_TIME_FORMAT; this.timezoneFormat = ImportTransactionDataMapping.DEFAULT_TIMEZONE_FORMAT; this.amountFormat = ImportTransactionDataMapping.DEFAULT_AMOUNT_FORMAT; this.geoLocationSeparator = ImportTransactionDataMapping.DEFAULT_GEO_LOCATION_SEPARATOR; this.geoLocationOrder = ImportTransactionDataMapping.DEFAULT_GEO_LOCATION_ORDER; this.tagSeparator = ImportTransactionDataMapping.DEFAULT_TAG_SEPARATOR; } public toJson(): string { return JSON.stringify({ [ImportTransactionDataMapping.JSON_ROOT_FIELD]: { includeHeader: this.includeHeader, dataColumnMapping: this.dataColumnMapping, transactionTypeMapping: this.transactionTypeMapping, timeFormat: this.timeFormat, timezoneFormat: this.timezoneFormat, amountFormat: this.amountFormat, geoLocationSeparator: this.geoLocationSeparator, geoLocationOrder: this.geoLocationOrder, tagSeparator: this.tagSeparator } }); } public static createEmpty(): ImportTransactionDataMapping { return new ImportTransactionDataMapping( ImportTransactionDataMapping.DEFAULT_INCLUDE_HEADER, {}, {}, ImportTransactionDataMapping.DEFAULT_TIME_FORMAT, ImportTransactionDataMapping.DEFAULT_TIMEZONE_FORMAT, ImportTransactionDataMapping.DEFAULT_AMOUNT_FORMAT, ImportTransactionDataMapping.DEFAULT_GEO_LOCATION_SEPARATOR, ImportTransactionDataMapping.DEFAULT_GEO_LOCATION_ORDER, ImportTransactionDataMapping.DEFAULT_TAG_SEPARATOR ); } public static parseFromJson(json: string): ImportTransactionDataMapping | null { try { const parsed = JSON.parse(json); const root = parsed[ImportTransactionDataMapping.JSON_ROOT_FIELD]; if (!root) { return null; } return new ImportTransactionDataMapping( root.includeHeader ?? ImportTransactionDataMapping.DEFAULT_INCLUDE_HEADER, root.dataColumnMapping ?? {}, root.transactionTypeMapping ?? {}, root.timeFormat ?? ImportTransactionDataMapping.DEFAULT_TIME_FORMAT, root.timezoneFormat ?? ImportTransactionDataMapping.DEFAULT_TIMEZONE_FORMAT, root.amountFormat ?? ImportTransactionDataMapping.DEFAULT_AMOUNT_FORMAT, root.geoLocationSeparator ?? ImportTransactionDataMapping.DEFAULT_GEO_LOCATION_SEPARATOR, root.geoLocationOrder ?? ImportTransactionDataMapping.DEFAULT_GEO_LOCATION_ORDER, root.tagSeparator ?? ImportTransactionDataMapping.DEFAULT_TAG_SEPARATOR ); } catch { return null; } } } export type ImportTransactionReplaceRuleDataType = 'expenseCategory' | 'incomeCategory' | 'transferCategory' | 'account' | 'tag'; export class ImportTransactionReplaceRule { public dataType: ImportTransactionReplaceRuleDataType; public sourceValue: string; public targetId: string; private constructor(dataType: ImportTransactionReplaceRuleDataType, sourceValue: string, targetId: string) { this.dataType = dataType; this.sourceValue = sourceValue; this.targetId = targetId; } public toJsonObject(): unknown { return { type: this.dataType, sourceValue: this.sourceValue, targetId: this.targetId }; } public static of(dataType: ImportTransactionReplaceRuleDataType, sourceValue: string, targetId: string): ImportTransactionReplaceRule { return new ImportTransactionReplaceRule(dataType, sourceValue, targetId); } public static parse(data: unknown): ImportTransactionReplaceRule | null { if (!data || typeof(data) !== 'object' || !('type' in data) || !('sourceValue' in data) || !('targetId' in data) || typeof(data.type) !== 'string' || typeof(data.sourceValue) !== 'string' || typeof(data.targetId) !== 'string') { return null; } if (data.type !== 'expenseCategory' && data.type !== 'incomeCategory' && data.type !== 'transferCategory' && data.type !== 'account' && data.type !== 'tag') { return null; } return new ImportTransactionReplaceRule(data.type as ImportTransactionReplaceRuleDataType, data.sourceValue as string, data.targetId as string); } } export class ImportTransactionReplaceRules { private static readonly JSON_ROOT_FIELD = 'ezBookkeepingImportTransactionReplaceRules'; private readonly rules: ImportTransactionReplaceRule[]; private constructor(rules: ImportTransactionReplaceRule[]) { this.rules = rules; } public getRules(): ImportTransactionReplaceRule[] { return this.rules; } public toJson(): string { const result: unknown[] = []; for (let i = 0; i < this.rules.length; i++) { const rule = this.rules[i]; result.push(rule.toJsonObject()); } return JSON.stringify({ [ImportTransactionReplaceRules.JSON_ROOT_FIELD]: result }); } public static of(rules: ImportTransactionReplaceRule[]): ImportTransactionReplaceRules { return new ImportTransactionReplaceRules(rules); } public static parseFromJson(json: string): ImportTransactionReplaceRules | null { try { const parsed = JSON.parse(json); const root = parsed[ImportTransactionReplaceRules.JSON_ROOT_FIELD]; if (!root || !('length' in root)) { return null; } const result = new ImportTransactionReplaceRules([]); for (let i = 0; i < root.length; i++) { const rule = root[i]; const replaceRule = ImportTransactionReplaceRule.parse(rule); if (replaceRule) { result.rules.push(replaceRule); } } return result; } catch { return null; } } }