set amount format in import dialog

This commit is contained in:
MaysWind
2025-03-23 17:21:52 +08:00
parent af03597e86
commit 66f7cc6f88
16 changed files with 344 additions and 46 deletions
+81
View File
@@ -144,6 +144,87 @@ export class DigitGroupingType implements TypeAndName {
}
}
export class KnownAmountFormat {
private static readonly allInstances: KnownAmountFormat[] = [];
private static readonly allInstancesByType: Record<string, KnownAmountFormat> = {};
public static readonly DotDecimalSeparator = new KnownAmountFormat('1234.56', DecimalSeparator.Dot, undefined, /^-?[0-9]+(\.[0-9]+)?$/);
public static readonly CommaDecimalSeparator = new KnownAmountFormat('1234,56', DecimalSeparator.Comma, undefined, /^-?[0-9]+(,[0-9]+)?$/);
public static readonly DotDecimalSeparatorWithCommaGroupingSymbol = new KnownAmountFormat('1,234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Comma, /^-?([0-9]+,)*[0-9]+(\.[0-9]+)?$/);
public static readonly CommaDecimalSeparatorWithDotGroupingSymbol = new KnownAmountFormat('1.234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Dot, /^-?([0-9]+\.)*[0-9]+(,[0-9]+)?$/);
public static readonly DotDecimalSeparatorWithSpaceGroupingSymbol = new KnownAmountFormat('1 234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Space, /^-?([0-9]+ )*[0-9]+(\.[0-9]+)?$/);
public static readonly CommaDecimalSeparatorWithSpaceGroupingSymbol = new KnownAmountFormat('1 234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Space, /^-?([0-9]+ )*[0-9]+(,[0-9]+)?$/);
public static readonly DotDecimalSeparatorWithApostropheGroupingSymbol = new KnownAmountFormat('1\'234.56', DecimalSeparator.Dot, DigitGroupingSymbol.Apostrophe, /^-?([0-9]+')*[0-9]+(\.[0-9]+)?$/);
public static readonly CommaDecimalSeparatorWithApostropheGroupingSymbol = new KnownAmountFormat('1\'234,56', DecimalSeparator.Comma, DigitGroupingSymbol.Apostrophe, /^-?([0-9]+')*[0-9]+(,[0-9]+)?$/);
public readonly format: string;
public readonly decimalSeparator: DecimalSeparator;
public readonly digitGroupingSymbol?: DigitGroupingSymbol;
public readonly type: string;
private readonly regex: RegExp;
private constructor(format: string, decimalSeparator: DecimalSeparator, digitGroupingSymbol: DigitGroupingSymbol | undefined, regex: RegExp) {
this.format = format;
this.decimalSeparator = decimalSeparator;
this.digitGroupingSymbol = digitGroupingSymbol;
this.type = this.decimalSeparator.type + '-' + (this.digitGroupingSymbol ? this.digitGroupingSymbol.type : 0).toString();
this.regex = regex;
KnownAmountFormat.allInstances.push(this);
KnownAmountFormat.allInstancesByType[this.type] = this;
}
public isValid(amount: string): boolean {
return this.regex.test(amount);
}
public static values(): KnownAmountFormat[] {
return KnownAmountFormat.allInstances;
}
public static valueOf(type: string): KnownAmountFormat | undefined {
return KnownAmountFormat.allInstancesByType[type];
}
public static detect(amount: string): KnownAmountFormat[] | undefined {
const result: KnownAmountFormat[] = [];
for (const format of KnownAmountFormat.allInstances) {
if (format.isValid(amount)) {
result.push(format);
}
}
return result.length > 0 ? result : undefined;
}
public static detectMulti(amounts: string[]): KnownAmountFormat[] | undefined {
const detectedCounts: Record<string, number> = {};
for (const amount of amounts) {
const detectedFormats = KnownAmountFormat.detect(amount);
if (detectedFormats) {
for (const format of detectedFormats) {
detectedCounts[format.type] = (detectedCounts[format.type] || 0) + 1;
}
} else {
return undefined;
}
}
const result: KnownAmountFormat[] = [];
for (const format of KnownAmountFormat.allInstances) {
if (detectedCounts[format.type] === amounts.length) {
result.push(format);
}
}
return result.length > 0 ? result : undefined;
}
}
export class AmountFilterType {
private static readonly allInstances: AmountFilterType[] = [];
private static readonly allInstancesByType: Record<string, AmountFilterType> = {};
+3 -1
View File
@@ -440,7 +440,7 @@ export default {
timeout: DEFAULT_UPLOAD_API_TIMEOUT
} as ApiRequestConfig);
},
parseImportTransaction: ({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record<number, number>, transactionTypeMapping?: Record<string, TransactionType>, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, geoSeparator?: string, tagSeparator?: string }): ApiResponsePromise<ImportTransactionResponsePageWrapper> => {
parseImportTransaction: ({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record<number, number>, transactionTypeMapping?: Record<string, TransactionType>, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, amountDecimalSeparator?: string, amountDigitGroupingSymbol?: string, geoSeparator?: string, tagSeparator?: string }): ApiResponsePromise<ImportTransactionResponsePageWrapper> => {
let textualColumnMapping: string | undefined = undefined;
let textualTransactionTypeMapping: string | undefined = undefined;
let textualHasHeaderLine: string | undefined = undefined;
@@ -466,6 +466,8 @@ export default {
hasHeaderLine: textualHasHeaderLine,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
geoSeparator: geoSeparator,
tagSeparator: tagSeparator
}, {
+2
View File
@@ -1668,6 +1668,7 @@
"Time Format": "Time Format",
"Transaction Type Mapping": "Transaction Type Mapping",
"Timezone Format": "Timezone Format",
"Amount Format": "Amount Format",
"Geographic Location Separator": "Geographic Location Separator",
"Transaction Tags Separator": "Transaction Tags Separator",
"Lines Per Page": "Lines Per Page",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping",
"Transaction type mapping is not set": "Transaction type mapping is not set",
"Transaction time format is not set": "Transaction time format is not set",
"Transaction amount format is not set": "Transaction amount format is not set",
"Cannot import invalid transactions": "Ungültige Transaktionen können nicht importiert werden",
"Unable to parse import file": "Importdatei kann nicht geparst werden",
"Batch Replace Selected Expense Categories": "Ausgewählte Ausgabenkategorien im Batch ersetzen",
+2
View File
@@ -1668,6 +1668,7 @@
"Time Format": "Time Format",
"Transaction Type Mapping": "Transaction Type Mapping",
"Timezone Format": "Timezone Format",
"Amount Format": "Amount Format",
"Geographic Location Separator": "Geographic Location Separator",
"Transaction Tags Separator": "Transaction Tags Separator",
"Lines Per Page": "Lines Per Page",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping",
"Transaction type mapping is not set": "Transaction type mapping is not set",
"Transaction time format is not set": "Transaction time format is not set",
"Transaction amount format is not set": "Transaction amount format is not set",
"Cannot import invalid transactions": "Cannot import invalid transactions",
"Unable to parse import file": "Unable to parse import file",
"Batch Replace Selected Expense Categories": "Batch Replace Selected Expense Categories",
+2
View File
@@ -1668,6 +1668,7 @@
"Time Format": "Time Format",
"Transaction Type Mapping": "Transaction Type Mapping",
"Timezone Format": "Timezone Format",
"Amount Format": "Amount Format",
"Geographic Location Separator": "Geographic Location Separator",
"Transaction Tags Separator": "Transaction Tags Separator",
"Lines Per Page": "Lines Per Page",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping",
"Transaction type mapping is not set": "Transaction type mapping is not set",
"Transaction time format is not set": "Transaction time format is not set",
"Transaction amount format is not set": "Transaction amount format is not set",
"Cannot import invalid transactions": "No se pueden importar transacciones no válidas",
"Unable to parse import file": "No se puede analizar el archivo de importación",
"Batch Replace Selected Expense Categories": "Reemplazar por lotes categorías de gastos seleccionadas",
+2
View File
@@ -1668,6 +1668,7 @@
"Time Format": "時刻形式",
"Transaction Type Mapping": "取引タイプのマッピング",
"Timezone Format": "タイムゾーン形式",
"Amount Format": "Amount Format",
"Geographic Location Separator": "地理座標の区切り",
"Transaction Tags Separator": "取引タグの区切り",
"Lines Per Page": "ページあたりの行数",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "取引時間、取引タイプ、または金額の列マッピングがありません",
"Transaction type mapping is not set": "取引タイプのマッピングが設定されていません",
"Transaction time format is not set": "取引時間の形式が設定されていません",
"Transaction amount format is not set": "Transaction amount format is not set",
"Cannot import invalid transactions": "無効な取引をインポートできません",
"Unable to parse import file": "インポートファイルを解析できません",
"Batch Replace Selected Expense Categories": "バッチは選択した支出カテゴリを置き換えます",
+2
View File
@@ -1668,6 +1668,7 @@
"Time Format": "Time Format",
"Transaction Type Mapping": "Transaction Type Mapping",
"Timezone Format": "Timezone Format",
"Amount Format": "Amount Format",
"Geographic Location Separator": "Geographic Location Separator",
"Transaction Tags Separator": "Transaction Tags Separator",
"Lines Per Page": "Lines Per Page",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping",
"Transaction type mapping is not set": "Transaction type mapping is not set",
"Transaction time format is not set": "Transaction time format is not set",
"Transaction amount format is not set": "Transaction amount format is not set",
"Cannot import invalid transactions": "Невозможно импортировать недействительные транзакции",
"Unable to parse import file": "Не удалось обработать файл импорта",
"Batch Replace Selected Expense Categories": "Пакетная замена выбранных категорий расходов",
+2
View File
@@ -1668,6 +1668,7 @@
"Time Format": "Time Format",
"Transaction Type Mapping": "Transaction Type Mapping",
"Timezone Format": "Timezone Format",
"Amount Format": "Amount Format",
"Geographic Location Separator": "Geographic Location Separator",
"Transaction Tags Separator": "Transaction Tags Separator",
"Lines Per Page": "Lines Per Page",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "Missing transaction time, transaction type, or amount column mapping",
"Transaction type mapping is not set": "Transaction type mapping is not set",
"Transaction time format is not set": "Transaction time format is not set",
"Transaction amount format is not set": "Transaction amount format is not set",
"Cannot import invalid transactions": "Không thể nhập giao dịch không hợp lệ",
"Unable to parse import file": "Không thể phân tích tệp nhập",
"Batch Replace Selected Expense Categories": "Thay thế hàng loạt các danh mục chi phí đã chọn",
+2
View File
@@ -1666,6 +1666,7 @@
"Please select a file to import": "请选择要导入的文件",
"Include Header Line": "包含标题行",
"Time Format": "时间格式",
"Amount Format": "金额格式",
"Transaction Type Mapping": "交易类型映射",
"Timezone Format": "时区格式",
"Geographic Location Separator": "地理位置分隔符",
@@ -1675,6 +1676,7 @@
"Missing transaction time, transaction type, or amount column mapping": "缺少交易时间、交易类型或金额列映射",
"Transaction type mapping is not set": "交易类型映射没有设置",
"Transaction time format is not set": "交易时间格式没有设置",
"Transaction amount format is not set": "交易金额格式没有设置",
"Cannot import invalid transactions": "不能导入无效的交易",
"Unable to parse import file": "无法解析导入的文件",
"Batch Replace Selected Expense Categories": "批量替换选中的支出分类",
+2 -2
View File
@@ -1081,9 +1081,9 @@ export const useTransactionsStore = defineStore('transactions', () => {
});
}
function parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record<number, number>, transactionTypeMapping?: Record<string, TransactionType>, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, geoSeparator?: string, tagSeparator?: string }): Promise<ImportTransactionResponsePageWrapper> {
function parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, tagSeparator }: { fileType: string, fileEncoding?: string, importFile: File, columnMapping?: Record<number, number>, transactionTypeMapping?: Record<string, TransactionType>, hasHeaderLine?: boolean, timeFormat?: string, timezoneFormat?: string, amountDecimalSeparator?: string, amountDigitGroupingSymbol?: string, geoSeparator?: string, tagSeparator?: string }): Promise<ImportTransactionResponsePageWrapper> {
return new Promise((resolve, reject) => {
services.parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, geoSeparator, tagSeparator }).then(response => {
services.parseImportTransaction({ fileType, fileEncoding, importFile, columnMapping, transactionTypeMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoSeparator, tagSeparator }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
@@ -377,6 +377,32 @@
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.Amount.type])">
<span>{{ tt('Amount Format') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.Amount.type])">({{ KnownAmountFormat.valueOf(parsedFileAmountFormat || parsedFileAutoDetectedAmountFormat || '')?.format || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileAmountFormat === '' ? mdiCheck : undefined"
@click="parsedFileAmountFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ml-1" v-if="parsedFileAutoDetectedAmountFormat && KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')">({{ KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')?.format }})</span>
<span class="ml-1" v-if="!parsedFileAutoDetectedAmountFormat || !KnownAmountFormat.valueOf(parsedFileAutoDetectedAmountFormat || '')">({{ tt('Unknown') }})</span>
</v-list-item-title>
</v-list-item>
<v-list-item :key="amountFormat.type"
:append-icon="parsedFileAmountFormat === amountFormat.type ? mdiCheck : undefined"
v-for="amountFormat in KnownAmountFormat.values()"
@click="parsedFileAmountFormat = amountFormat.type">
<v-list-item-title class="cursor-pointer">
{{ amountFormat.format }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.GeographicLocation.type])">
<span>{{ tt('Geographic Location Separator') }}</span>
@@ -812,6 +838,7 @@ import { useOverviewStore } from '@/stores/overview.ts';
import { useStatisticsStore } from '@/stores/statistics.ts';
import type { NameValue, TypeAndDisplayName } from '@/core/base.ts';
import { KnownAmountFormat } from '@/core/numeral.ts';
import { KnownDateTimeFormat } from '@/core/datetime.ts';
import { KnownDateTimezoneFormat } from '@/core/timezone.ts';
import { CategoryType } from '@/core/category.ts';
@@ -930,6 +957,7 @@ const parsedFileDataColumnMapping = ref<Record<number, number>>({});
const parsedFileTransactionTypeMapping = ref<Record<string, TransactionType>>({});
const parsedFileTimeFormat = ref<string>('');
const parsedFileTimezoneFormat = ref<string>('');
const parsedFileAmountFormat = ref<string>('');
const parsedFileGeoLocationSeparator = ref<string>(' ');
const parsedFileTagSeparator = ref<string>(';');
const importTransactions = ref<ImportTransaction[] | undefined>(undefined);
@@ -1288,6 +1316,37 @@ const parsedFileAutoDetectedTimezoneFormat = computed<string | undefined>(() =>
return detectedFormats[0].value;
});
const parsedFileAutoDetectedAmountFormat = computed<string | undefined>(() => {
if (!parsedFileData.value || !parsedFileData.value.length || !isNumber(parsedFileDataColumnMapping.value[ImportTransactionColumnType.Amount.type])) {
return undefined;
}
const allAmounts: string[] = [];
const amountColumnIndex = parsedFileDataColumnMapping.value[ImportTransactionColumnType.Amount.type];
const startIndex = parsedFileIncludeHeader.value ? 1 : 0;
for (let i = startIndex; i < parsedFileData.value.length; i++) {
if (parsedFileData.value[i].length <= amountColumnIndex) {
continue;
}
const amount = parsedFileData.value[i][amountColumnIndex];
if (amount) {
allAmounts.push(amount);
}
}
const detectedFormats = KnownAmountFormat.detectMulti(allAmounts);
if (!detectedFormats || !detectedFormats.length) {
return undefined;
}
return detectedFormats[0].type;
});
const importTransactionsTableHeight = computed<number | undefined>(() => {
if (countPerPage.value <= 10 || !importTransactions.value || importTransactions.value.length <= 10) {
return undefined;
@@ -1853,6 +1912,7 @@ function open(): Promise<void> {
parsedFileTransactionTypeMapping.value = {};
parsedFileTimeFormat.value = '';
parsedFileTimezoneFormat.value = '';
parsedFileAmountFormat.value = '';
parsedFileGeoLocationSeparator.value = ' ';
parsedFileTagSeparator.value = ';';
importTransactions.value = undefined;
@@ -1995,6 +2055,9 @@ function parseData(): void {
let hasHeaderLine: boolean | undefined = undefined;
let timeFormat: string | undefined = undefined;
let timezoneFormat: string | undefined = undefined;
let amountFormat: string | undefined = undefined;
let amountDecimalSeparator: string | undefined = undefined;
let amountDigitGroupingSymbol: string | undefined = undefined;
let geoLocationSeparator: string | undefined = undefined;
let tagSeparator: string | undefined = undefined;
@@ -2004,6 +2067,7 @@ function parseData(): void {
hasHeaderLine = parsedFileIncludeHeader.value;
timeFormat = parsedFileTimeFormat.value;
timezoneFormat = parsedFileTimezoneFormat.value;
amountFormat = parsedFileAmountFormat.value;
geoLocationSeparator = parsedFileGeoLocationSeparator.value;
tagSeparator = parsedFileTagSeparator.value;
@@ -2028,10 +2092,28 @@ function parseData(): void {
timezoneFormat = parsedFileAutoDetectedTimezoneFormat.value;
}
if (!parsedFileAmountFormat.value) {
amountFormat = parsedFileAutoDetectedAmountFormat.value;
}
if (amountFormat) {
const knownAmountFormat = KnownAmountFormat.valueOf(amountFormat);
if (knownAmountFormat) {
amountDecimalSeparator = knownAmountFormat.decimalSeparator.symbol;
amountDigitGroupingSymbol = knownAmountFormat.digitGroupingSymbol?.symbol;
}
}
if (!timeFormat) {
snackbar.value?.showError('Transaction time format is not set');
return;
}
if (!amountDecimalSeparator) {
snackbar.value?.showError('Transaction amount format is not set');
return;
}
}
submitting.value = true;
@@ -2045,6 +2127,8 @@ function parseData(): void {
hasHeaderLine: hasHeaderLine,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
geoSeparator: geoLocationSeparator,
tagSeparator: tagSeparator
}).then(response => {