save / load column mapping file for delimiter-separated values (dsv) file

This commit is contained in:
MaysWind
2025-06-22 22:49:06 +08:00
parent dfa573b49b
commit 3621245212
17 changed files with 529 additions and 271 deletions
+1
View File
@@ -1,6 +1,7 @@
export class KnownFileType {
private static readonly allInstancesByExtension: Record<string, KnownFileType> = {};
public static readonly JSON = new KnownFileType('json', 'application/json');
public static readonly CSV = new KnownFileType('csv', 'text/csv');
public static readonly TSV = new KnownFileType('tsv', 'text/tab-separated-values');
public static readonly MARKDOWN = new KnownFileType('md', 'text/markdown');
+327
View File
@@ -0,0 +1,327 @@
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<number, number>;
public transactionTypeMapping: Record<string, TransactionType>;
public timeFormat: string;
public timezoneFormat: string;
public amountFormat: string;
public geoLocationSeparator: string;
public geoLocationOrder: string;
public tagSeparator: string;
private constructor(includeHeader: boolean,
dataColumnMapping: Record<number, number>,
transactionTypeMapping: Record<string, TransactionType>,
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<string, boolean> = {};
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<string, TransactionType> {
if (!fileData || !fileData.length || !this.isColumnMappingSet(ImportTransactionColumnType.TransactionType)) {
return {};
}
const result: Record<string, TransactionType> = {};
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.TransactionTimezone)) {
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;
}
}
}
-33
View File
@@ -57,36 +57,3 @@ export class TransactionTagFilterType implements TypeAndName {
return TransactionTagFilterType.allInstances;
}
}
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;
}
}
+39
View File
@@ -4,6 +4,8 @@ import { ThemeType } from '@/core/theme.ts';
import { type AmountColor, PresetAmountColor } from '@/core/color.ts';
import logger from '../logger.ts';
export function getSystemTheme(): ThemeType {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return ThemeType.Dark;
@@ -84,6 +86,43 @@ export function copyTextToClipboard(text: string, container?: Element | null): v
});
}
export function openTextFileContent({ allowedExtensions }: { allowedExtensions: string }): Promise<string> {
return new Promise((resolve, reject) => {
const fileInput = document.createElement('input');
fileInput.style.display = 'none';
fileInput.type = 'file';
fileInput.accept = allowedExtensions;
fileInput.onchange = (event) => {
const el = event.target as HTMLInputElement;
if (el.files && el.files.length > 0) {
const file = el.files[0];
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result;
if (typeof content === 'string') {
resolve(content);
} else {
reject(new Error('failed to load file, because file reader result is not string'));
}
};
reader.onerror = (error) => {
logger.error('failed to load file', error);
};
reader.readAsText(file);
}
};
fileInput.click();
});
}
export function startDownloadFile(fileName: string, fileData: Blob): void {
const dataObjectUrl = URL.createObjectURL(fileData);
const dataLink = document.createElement('a');
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "Ungültige Transaktionen können nicht importiert werden",
"Unable to parse import file": "Importdatei kann nicht geparst werden",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Ausgewählte Ausgabenkategorien im Batch ersetzen",
"Batch Replace Selected Income Categories": "Ausgewählte Einnahmenkategorien im Batch ersetzen",
"Batch Replace Selected Transfer Categories": "Ausgewählte Überweisungskategorien im Batch ersetzen",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "Cannot import invalid transactions",
"Unable to parse import file": "Unable to parse import file",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Batch Replace Selected Expense Categories",
"Batch Replace Selected Income Categories": "Batch Replace Selected Income Categories",
"Batch Replace Selected Transfer Categories": "Batch Replace Selected Transfer Categories",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"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",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Reemplazar por lotes categorías de gastos seleccionadas",
"Batch Replace Selected Income Categories": "Reemplazo por lotes de categorías de ingresos seleccionadas",
"Batch Replace Selected Transfer Categories": "Reemplazar por lotes las categorías de transferencia seleccionadas",
+5 -2
View File
@@ -79,10 +79,13 @@ import {
import {
TransactionEditScopeType,
TransactionTagFilterType,
ImportTransactionColumnType
TransactionTagFilterType
} from '@/core/transaction.ts';
import {
ImportTransactionColumnType
} from '@/core/import_transaction.ts';
import {
ScheduledTemplateFrequencyType
} from '@/core/template.ts';
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "Impossibile importare transazioni non valide",
"Unable to parse import file": "Impossibile analizzare il file di importazione",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Sostituisci in blocco categorie di spesa selezionate",
"Batch Replace Selected Income Categories": "Sostituisci in blocco categorie di entrata selezionate",
"Batch Replace Selected Transfer Categories": "Sostituisci in blocco categorie di trasferimento selezionate",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_エクスポートデータ",
"exportFilename": "ezBookkeeping_{nickname}_エクスポートデータ",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "無効な取引をインポートできません",
"Unable to parse import file": "インポートファイルを解析できません",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "バッチは選択した支出カテゴリを置き換えます",
"Batch Replace Selected Income Categories": "バッチは選択した収入カテゴリを置き換えます",
"Batch Replace Selected Transfer Categories": "バッチは選択した振替カテゴリを置き換えます",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_dados_estatísticos",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_dados_estatísticos"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_dados_estatísticos",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "Não é possível importar transações inválidas",
"Unable to parse import file": "Não foi possível analisar o arquivo de importação",
"Unable to import transactions": "Não foi possível importar transações",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Substituir em Lote as Categorias de Despesas Selecionadas",
"Batch Replace Selected Income Categories": "Substituir em Lote as Categorias de Renda Selecionadas",
"Batch Replace Selected Transfer Categories": "Substituir em Lote as Categorias de Transferência Selecionadas",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "Невозможно импортировать недействительные транзакции",
"Unable to parse import file": "Не удалось обработать файл импорта",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Пакетная замена выбранных категорий расходов",
"Batch Replace Selected Income Categories": "Пакетная замена выбранных категорий доходов",
"Batch Replace Selected Transfer Categories": "Пакетная замена выбранных категорий переводов",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "Неможливо імпортувати недійсні транзакції",
"Unable to parse import file": "Не вдалося обробити файл імпорту",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Пакетна заміна вибраних категорій витрат",
"Batch Replace Selected Income Categories": "Пакетна заміна вибраних категорій доходів",
"Batch Replace Selected Transfer Categories": "Пакетна заміна вибраних категорій переказів",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_export_data",
"exportFilename": "ezBookkeeping_{nickname}_export_data",
"defaultExportStatisticsFileName": "ezBookkeeping_statistics_data",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_statistics_data",
"defaultImportDataMappingFileName": "ezBookkeeping_import_data_mapping"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"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",
"Unable to import transactions": "Unable to import transactions",
"Load Data Mapping File": "Load Data Mapping File",
"Save Data Mapping File": "Save Data Mapping File",
"Data mapping file is invalid": "Data mapping file is invalid",
"Batch Replace Selected Expense Categories": "Thay thế hàng loạt các danh mục chi phí đã chọn",
"Batch Replace Selected Income Categories": "Thay thế hàng loạt các danh mục thu nhập đã chọn",
"Batch Replace Selected Transfer Categories": "Thay thế hàng loạt các danh mục chuyển khoản đã chọn",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_导出数据",
"exportFilename": "ezBookkeeping_{nickname}_导出数据",
"defaultExportStatisticsFileName": "ezBookkeeping_统计数据",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_统计数据"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_统计数据",
"defaultImportDataMappingFileName": "ezBookkeeping_导入数据映射文件"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "不能导入无效的交易",
"Unable to parse import file": "无法解析导入的文件",
"Unable to import transactions": "无法导入交易",
"Load Data Mapping File": "加载数据映射文件",
"Save Data Mapping File": "保存数据映射文件",
"Data mapping file is invalid": "数据映射文件无效",
"Batch Replace Selected Expense Categories": "批量替换选中的支出分类",
"Batch Replace Selected Income Categories": "批量替换选中的收入分类",
"Batch Replace Selected Transfer Categories": "批量替换选中的转账分类",
+5 -1
View File
@@ -121,7 +121,8 @@
"defaultExportFilename": "ezBookkeeping_匯出資料",
"exportFilename": "ezBookkeeping_{nickname}_匯出資料",
"defaultExportStatisticsFileName": "ezBookkeeping_統計資料",
"exportStatisticsFileName": "ezBookkeeping_{nickname}_統計資料"
"exportStatisticsFileName": "ezBookkeeping_{nickname}_統計資料",
"defaultImportDataMappingFileName": "ezBookkeeping_匯入資料對應檔案"
},
"datetime": {
"AM": {
@@ -1728,6 +1729,9 @@
"Cannot import invalid transactions": "無法匯入無效的交易",
"Unable to parse import file": "無法解析匯入的檔案",
"Unable to import transactions": "無法匯入交易",
"Load Data Mapping File": "載入資料對應檔案",
"Save Data Mapping File": "儲存資料對應檔案",
"Data mapping file is invalid": "資料對應檔案無效",
"Batch Replace Selected Expense Categories": "批次替換選中的支出分類",
"Batch Replace Selected Income Categories": "批次替換選中的收入分類",
"Batch Replace Selected Transfer Categories": "批次替換選中的轉帳分類",
@@ -7,6 +7,21 @@
<h4 class="text-h4">{{ tt('Import Transactions') }}</h4>
<v-progress-circular indeterminate size="22" class="ml-2" v-if="loading"></v-progress-circular>
</div>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:icon="true" :disabled="loading || submitting"
v-if="currentStep === 'defineColumn'">
<v-icon :icon="mdiDotsVertical" />
<v-menu activator="parent" max-height="500">
<v-list>
<v-list-item :prepend-icon="mdiFolderOpenOutline"
:title="tt('Load Data Mapping File')"
@click="loadColumnMappingFile()"></v-list-item>
<v-list-item :prepend-icon="mdiContentSaveOutline"
:title="tt('Save Data Mapping File')"
@click="saveColumnMappingFile()"></v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn density="comfortable" color="default" variant="text" class="ml-2"
:icon="true" :disabled="loading || submitting"
v-if="currentStep === 'checkData'">
@@ -294,9 +309,9 @@
<v-menu activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item :key="columnType.type"
:append-icon="parsedFileDataColumnMapping[columnType.type] === parseInt(column.key) ? mdiCheck : undefined"
:append-icon="parsedFileDataColumnMapping.dataColumnMapping[columnType.type] === parseInt(column.key) ? mdiCheck : undefined"
v-for="columnType in allImportTransactionColumnTypes"
@click="updateParseDataMappedColumn(parseInt(column.key), columnType.type)">
@click="parsedFileDataColumnMapping.toggleDataMappingColumn(parseInt(column.key), columnType.type)">
<v-list-item-title class="cursor-pointer">
{{ columnType.displayName }}
</v-list-item-title>
@@ -310,12 +325,12 @@
<template #bottom>
<div class="title-and-toolbar d-flex align-center text-no-wrap mt-2" v-if="parsedFileData">
<v-btn color="secondary" density="compact" variant="outlined"
:append-icon="parsedFileIncludeHeader ? mdiCheck : mdiClose"
@click="parsedFileIncludeHeader = !parsedFileIncludeHeader">{{ tt('Include Header Line') }}</v-btn>
:append-icon="parsedFileDataColumnMapping.includeHeader ? mdiCheck : mdiClose"
@click="parsedFileDataColumnMapping.toggleIncludeHeader()">{{ tt('Include Header Line') }}</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.TransactionType.type]) || !parsedFileAllTransactionTypes">
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionType) || !parsedFileAllTransactionTypes">
<span>{{ tt('Transaction Type Mapping') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.TransactionType.type]) && parsedFileAllTransactionTypes">({{ getObjectOwnFieldCount(parsedFileValidMappedTransactionTypes) || tt('None') }})</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionType) && parsedFileAllTransactionTypes">({{ getObjectOwnFieldCount(parsedFileValidMappedTransactionTypes) || tt('None') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500"
:close-on-content-click="false">
<v-list class="pa-0">
@@ -328,7 +343,7 @@
<td>
<v-btn-toggle class="transaction-types-toggle" density="compact" variant="outlined"
mandatory="force" divided
v-model="parsedFileTransactionTypeMapping[typeName]">
v-model="parsedFileDataColumnMapping.transactionTypeMapping[typeName]">
<v-btn :value="undefined">{{ tt('None') }}</v-btn>
<v-btn :value="TransactionType.ModifyBalance">{{ tt('Modify Balance') }}</v-btn>
<v-btn :value="TransactionType.Income">{{ tt('Income') }}</v-btn>
@@ -344,14 +359,14 @@
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.TransactionTime.type])">
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)">
<span>{{ tt('Time Format') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.TransactionTime.type])">({{ parsedFileTimeFormat || parsedFileAutoDetectedTimeFormat || tt('Unknown') }})</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTime)">({{ parsedFileDataColumnMapping.timeFormat || parsedFileAutoDetectedTimeFormat || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileTimeFormat === '' ? mdiCheck : undefined"
@click="parsedFileTimeFormat = ''">
:append-icon="parsedFileDataColumnMapping.timeFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.timeFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ml-1" v-if="parsedFileAutoDetectedTimeFormat">({{ parsedFileAutoDetectedTimeFormat }})</span>
@@ -359,9 +374,9 @@
</v-list-item-title>
</v-list-item>
<v-list-item :key="dateTimeFormat.format"
:append-icon="parsedFileTimeFormat === dateTimeFormat.format ? mdiCheck : undefined"
:append-icon="parsedFileDataColumnMapping.timeFormat === dateTimeFormat.format ? mdiCheck : undefined"
v-for="dateTimeFormat in KnownDateTimeFormat.values()"
@click="parsedFileTimeFormat = dateTimeFormat.format">
@click="parsedFileDataColumnMapping.timeFormat = dateTimeFormat.format">
<v-list-item-title class="cursor-pointer">
{{ dateTimeFormat.format }}
</v-list-item-title>
@@ -370,14 +385,14 @@
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.TransactionTimezone.type])">
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)">
<span>{{ tt('Timezone Format') }}</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.TransactionTimezone.type])">({{ KnownDateTimezoneFormat.valueOf(parsedFileTimezoneFormat || parsedFileAutoDetectedTimezoneFormat || '')?.name || tt('Unknown') }})</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.TransactionTimezone)">({{ KnownDateTimezoneFormat.valueOf(parsedFileDataColumnMapping.timezoneFormat || parsedFileAutoDetectedTimezoneFormat || '')?.name || tt('Unknown') }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item key="auto"
:append-icon="parsedFileTimezoneFormat === '' ? mdiCheck : undefined"
@click="parsedFileTimezoneFormat = ''">
:append-icon="parsedFileDataColumnMapping.timezoneFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.timezoneFormat = ''">
<v-list-item-title class="cursor-pointer">
<span>{{ tt('Auto detect') }}</span>
<span class="ml-1" v-if="parsedFileAutoDetectedTimezoneFormat && KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')">({{ KnownDateTimezoneFormat.valueOf(parsedFileAutoDetectedTimezoneFormat || '')?.name }})</span>
@@ -385,9 +400,9 @@
</v-list-item-title>
</v-list-item>
<v-list-item :key="timezoneFormat.value"
:append-icon="parsedFileTimezoneFormat === timezoneFormat.value ? mdiCheck : undefined"
:append-icon="parsedFileDataColumnMapping.timezoneFormat === timezoneFormat.value ? mdiCheck : undefined"
v-for="timezoneFormat in KnownDateTimezoneFormat.values()"
@click="parsedFileTimezoneFormat = timezoneFormat.value">
@click="parsedFileDataColumnMapping.timezoneFormat = timezoneFormat.value">
<v-list-item-title class="cursor-pointer">
{{ timezoneFormat.name }}
</v-list-item-title>
@@ -396,14 +411,14 @@
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
:disabled="!parsedFileDataColumnMapping || !isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.Amount.type])">
:disabled="!parsedFileDataColumnMapping || !parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Amount)">
<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>
<span class="ml-1" v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Amount)">({{ KnownAmountFormat.valueOf(parsedFileDataColumnMapping.amountFormat || 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 = ''">
:append-icon="parsedFileDataColumnMapping.amountFormat === '' ? mdiCheck : undefined"
@click="parsedFileDataColumnMapping.amountFormat = ''">
<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>
@@ -411,9 +426,9 @@
</v-list-item-title>
</v-list-item>
<v-list-item :key="amountFormat.type"
:append-icon="parsedFileAmountFormat === amountFormat.type ? mdiCheck : undefined"
:append-icon="parsedFileDataColumnMapping.amountFormat === amountFormat.type ? mdiCheck : undefined"
v-for="amountFormat in KnownAmountFormat.values()"
@click="parsedFileAmountFormat = amountFormat.type">
@click="parsedFileDataColumnMapping.amountFormat = amountFormat.type">
<v-list-item-title class="cursor-pointer">
{{ amountFormat.format }}
</v-list-item-title>
@@ -422,9 +437,9 @@
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.GeographicLocation.type])">
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.GeographicLocation)">
<span>{{ tt('Geographic Location Separator') }}</span>
<span class="ml-1" v-if="parsedFileGeoLocationSeparator">({{ parsedFileGeoLocationOrder === 'latlon' ? `${tt('Latitude')}${parsedFileGeoLocationSeparator}${tt('Longitude')}` : `${tt('Longitude')}${parsedFileGeoLocationSeparator}${tt('Latitude')}` }})</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping.geoLocationOrder">({{ parsedFileDataColumnMapping.formatGeoLocation(tt('Latitude'), tt('Longitude')) }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500"
:close-on-content-click="false">
<v-list class="pa-0">
@@ -437,15 +452,15 @@
<td>
<v-btn-toggle class="transaction-types-toggle" density="compact" variant="outlined"
mandatory="force" divided
v-model="parsedFileGeoLocationOrder"
v-if="parsedFileGeoLocationSeparator === separator.value">
v-model="parsedFileDataColumnMapping.geoLocationOrder"
v-if="parsedFileDataColumnMapping.geoLocationSeparator === separator.value">
<v-btn value="latlon">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn value="lonlat">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
</v-btn-toggle>
<v-btn-group class="transaction-types-toggle" density="compact" variant="outlined"
divided v-if="parsedFileGeoLocationSeparator !== separator.value">
<v-btn @click="parsedFileGeoLocationSeparator = separator.value; parsedFileGeoLocationOrder = 'latlon'">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn @click="parsedFileGeoLocationSeparator = separator.value; parsedFileGeoLocationOrder = 'lonlat'">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
divided v-if="parsedFileDataColumnMapping.geoLocationSeparator !== separator.value">
<v-btn @click="parsedFileDataColumnMapping.setGeoLocationFormat(separator.value, 'latlon')">{{ `${tt('Latitude')}${separator.value}${tt('Longitude')}` }}</v-btn>
<v-btn @click="parsedFileDataColumnMapping.setGeoLocationFormat(separator.value, 'lonlat')">{{ `${tt('Longitude')}${separator.value}${tt('Latitude')}` }}</v-btn>
</v-btn-group>
</td>
</tr>
@@ -456,15 +471,15 @@
</v-menu>
</v-btn>
<v-btn class="ml-2" color="secondary" density="compact" variant="outlined"
v-if="parsedFileDataColumnMapping && isNumber(parsedFileDataColumnMapping[ImportTransactionColumnType.Tags.type])">
v-if="parsedFileDataColumnMapping && parsedFileDataColumnMapping.isColumnMappingSet(ImportTransactionColumnType.Tags)">
<span>{{ tt('Transaction Tags Separator') }}</span>
<span class="ml-1" v-if="parsedFileTagSeparator">({{ parsedFileTagSeparator }})</span>
<span class="ml-1" v-if="parsedFileDataColumnMapping.tagSeparator">({{ parsedFileDataColumnMapping.tagSeparator }})</span>
<v-menu eager activator="parent" location="bottom" max-height="500">
<v-list>
<v-list-item :key="separator.value"
:append-icon="parsedFileTagSeparator === separator.value ? mdiCheck : undefined"
:append-icon="parsedFileDataColumnMapping.tagSeparator === separator.value ? mdiCheck : undefined"
v-for="separator in allSeparators"
@click="parsedFileTagSeparator = separator.value">
@click="parsedFileDataColumnMapping.tagSeparator = separator.value">
<v-list-item-title class="cursor-pointer">
{{ separator.name }} ({{separator.value}})
</v-list-item-title>
@@ -879,7 +894,10 @@ import { KnownAmountFormat } from '@/core/numeral.ts';
import { KnownDateTimeFormat } from '@/core/datetime.ts';
import { KnownDateTimezoneFormat } from '@/core/timezone.ts';
import { CategoryType } from '@/core/category.ts';
import { TransactionType, ImportTransactionColumnType } from '@/core/transaction.ts';
import { TransactionType } from '@/core/transaction.ts';
import { ImportTransactionColumnType, ImportTransactionDataMapping } from '@/core/import_transaction.ts';
import { KnownFileType } from '@/core/file.ts';
import type { LocalizedImportFileType, LocalizedImportFileTypeSubType, LocalizedImportFileTypeSupportedEncodings } from '@/core/file.ts';
import { Account, type CategorizedAccountWithDisplayBalance } from '@/models/account.ts';
import type { TransactionCategory } from '@/models/transaction_category.ts';
@@ -916,12 +934,18 @@ import {
getTransactionPrimaryCategoryName,
getTransactionSecondaryCategoryName
} from '@/lib/category.ts';
import {
openTextFileContent,
startDownloadFile
} from '@/lib/ui/common.ts';
import {
mdiFilterOutline,
mdiCheck,
mdiDotsVertical,
mdiHelpCircleOutline,
mdiFolderOpenOutline,
mdiContentSaveOutline,
mdiFindReplace,
mdiShapePlusOutline,
mdiTransfer,
@@ -996,15 +1020,7 @@ const fileEncoding = ref<string>('utf-8');
const importFile = ref<File | null>(null);
const importData = ref<string>('');
const parsedFileData = ref<string[][] | undefined>(undefined);
const parsedFileIncludeHeader = ref<boolean>(true);
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 parsedFileGeoLocationOrder = ref<string>('lonlat');
const parsedFileTagSeparator = ref<string>(';');
const parsedFileDataColumnMapping = ref<ImportTransactionDataMapping>(ImportTransactionDataMapping.createEmpty());
const importTransactions = ref<ImportTransaction[] | undefined>(undefined);
const editingTransaction = ref<ImportTransaction | null>(null);
const editingTags = ref<string[]>([]);
@@ -1192,7 +1208,7 @@ const parsedFileLines = computed<Record<string, string>[] | undefined>(() => {
}
const allLines: Record<string, string>[] = [];
const startIndex = parsedFileIncludeHeader.value ? 1 : 0;
const startIndex = parsedFileDataColumnMapping.value.includeHeader ? 1 : 0;
for (let i = startIndex, index = 1; i < parsedFileData.value.length; i++, index++) {
const line: Record<string, string> = {};
@@ -1235,7 +1251,7 @@ const parsedFileLinesHeaders = computed<object[]>(() => {
for (let i = 0; i < maxColumnCount; i++) {
let title = `#${i + 1}`;
if (parsedFileIncludeHeader.value && parsedFileData.value && parsedFileData.value[0][i]) {
if (parsedFileDataColumnMapping.value.includeHeader && parsedFileData.value && parsedFileData.value[0][i]) {
title = parsedFileData.value[0][i];
}
@@ -1247,151 +1263,11 @@ const parsedFileLinesHeaders = computed<object[]>(() => {
const parsedFileLinesTablePageOptions = computed<ImportTransactionsDialogTablePageOption[]>(() => getTablePageOptions(parsedFileLines.value?.length));
const parsedFileAllTransactionTypes = computed<string[]>(() => {
if (!parsedFileData.value || !parsedFileData.value.length || !isNumber(parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionType.type])) {
return [];
}
const allTypeMap: Record<string, boolean> = {};
const allTypes: string[] = [];
const typeColumnIndex = parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionType.type];
const startIndex = parsedFileIncludeHeader.value ? 1 : 0;
for (let i = startIndex; i < parsedFileData.value.length; i++) {
if (parsedFileData.value[i].length <= typeColumnIndex) {
continue;
}
const type = parsedFileData.value[i][typeColumnIndex];
if (type && !allTypeMap[type]) {
allTypes.push(type);
allTypeMap[type] = true;
}
}
return allTypes;
});
const parsedFileValidMappedTransactionTypes = computed<Record<string, TransactionType>>(() => {
if (!parsedFileData.value || !parsedFileData.value.length || !isNumber(parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionType.type])) {
return {};
}
const result: Record<string, TransactionType> = {};
if (!parsedFileTransactionTypeMapping.value) {
return result;
}
for (const name in parsedFileTransactionTypeMapping.value) {
if (!Object.prototype.hasOwnProperty.call(parsedFileTransactionTypeMapping.value, name)) {
continue;
}
const type = parsedFileTransactionTypeMapping.value[name];
if (TransactionType.ModifyBalance <= type && type <= TransactionType.Transfer) {
result[name] = type;
}
}
return result;
});
const parsedFileAutoDetectedTimeFormat = computed<string | undefined>(() => {
if (!parsedFileData.value || !parsedFileData.value.length || !isNumber(parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionTime.type])) {
return undefined;
}
const allDateTimes: string[] = [];
const dateTimeColumnIndex = parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionTime.type];
const startIndex = parsedFileIncludeHeader.value ? 1 : 0;
for (let i = startIndex; i < parsedFileData.value.length; i++) {
if (parsedFileData.value[i].length <= dateTimeColumnIndex) {
continue;
}
const dateTime = parsedFileData.value[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;
});
const parsedFileAutoDetectedTimezoneFormat = computed<string | undefined>(() => {
if (!parsedFileData.value || !parsedFileData.value.length || !isNumber(parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionTimezone.type])) {
return undefined;
}
const allTimezones: string[] = [];
const timezoneColumnIndex = parsedFileDataColumnMapping.value[ImportTransactionColumnType.TransactionTimezone.type];
const startIndex = parsedFileIncludeHeader.value ? 1 : 0;
for (let i = startIndex; i < parsedFileData.value.length; i++) {
if (parsedFileData.value[i].length <= timezoneColumnIndex) {
continue;
}
const timezone = parsedFileData.value[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;
});
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 parsedFileAllTransactionTypes = computed<string[]>(() => parsedFileDataColumnMapping.value.parseFileAllTransactionTypes(parsedFileData.value));
const parsedFileValidMappedTransactionTypes = computed<Record<string, TransactionType>>(() => parsedFileDataColumnMapping.value.parseFileValidMappedTransactionTypes(parsedFileData.value));
const parsedFileAutoDetectedTimeFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedTimeFormat(parsedFileData.value));
const parsedFileAutoDetectedTimezoneFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedTimezoneFormat(parsedFileData.value));
const parsedFileAutoDetectedAmountFormat = computed<string | undefined>(() => parsedFileDataColumnMapping.value.parseFileAutoDetectedAmountFormat(parsedFileData.value));
const importTransactionsTableHeight = computed<number | undefined>(() => {
if (countPerPage.value <= 10 || !importTransactions.value || importTransactions.value.length <= 10) {
@@ -1649,8 +1525,8 @@ function getTablePageOptions(linesCount?: number): ImportTransactionsDialogTable
}
function getParseDataMappedColumnDisplayName(columnIndex: number): string {
for (const columnType in parsedFileDataColumnMapping.value) {
if (parsedFileDataColumnMapping.value[columnType] === columnIndex) {
for (const columnType in parsedFileDataColumnMapping.value.dataColumnMapping) {
if (parsedFileDataColumnMapping.value.dataColumnMapping[columnType] === columnIndex) {
return findDisplayNameByType(allImportTransactionColumnTypes.value, parseInt(columnType)) || tt('Unspecified');
}
}
@@ -1658,18 +1534,27 @@ function getParseDataMappedColumnDisplayName(columnIndex: number): string {
return tt('Unspecified');
}
function updateParseDataMappedColumn(columnIndex: number, columnType: number): void {
if (parsedFileDataColumnMapping.value[columnType] === columnIndex) {
delete parsedFileDataColumnMapping.value[columnType];
} else {
parsedFileDataColumnMapping.value[columnType] = columnIndex;
}
function loadColumnMappingFile(): void {
openTextFileContent({
allowedExtensions: KnownFileType.JSON.contentType
}).then(content => {
const result = ImportTransactionDataMapping.parseFromJson(content);
for (const otherColumnType in parsedFileDataColumnMapping.value) {
if (otherColumnType !== columnType.toString() && parsedFileDataColumnMapping.value[otherColumnType] === columnIndex) {
delete parsedFileDataColumnMapping.value[otherColumnType];
if (result) {
parsedFileDataColumnMapping.value = result;
} else {
logger.error('Failed to parse data mapping file');
snackbar.value?.showError('Data mapping file is invalid');
}
}
}).catch(error => {
logger.error('Failed to open data mapping file', error);
snackbar.value?.showError('Data mapping file is invalid');
});
}
function saveColumnMappingFile(): void {
const fileName = KnownFileType.JSON.formatFileName(tt('dataExport.defaultImportDataMappingFileName'));
startDownloadFile(fileName, KnownFileType.JSON.createBlob(parsedFileDataColumnMapping.value.toJson()));
}
function isTransactionDisplayed(transaction: ImportTransaction): boolean {
@@ -1954,15 +1839,7 @@ function open(): Promise<void> {
importFile.value = null;
importData.value = '';
parsedFileData.value = undefined;
parsedFileIncludeHeader.value = true;
parsedFileDataColumnMapping.value = {};
parsedFileTransactionTypeMapping.value = {};
parsedFileTimeFormat.value = '';
parsedFileTimezoneFormat.value = '';
parsedFileAmountFormat.value = '';
parsedFileGeoLocationSeparator.value = ' ';
parsedFileGeoLocationOrder.value = 'lonlat';
parsedFileTagSeparator.value = ';';
parsedFileDataColumnMapping.value.reset();
importTransactions.value = undefined;
editingTransaction.value = null;
editingTags.value = [];
@@ -2111,15 +1988,15 @@ function parseData(): void {
let tagSeparator: string | undefined = undefined;
if (isDsvFileType) {
columnMapping = parsedFileDataColumnMapping.value;
columnMapping = parsedFileDataColumnMapping.value.dataColumnMapping;
transactionTypeMapping = parsedFileValidMappedTransactionTypes.value;
hasHeaderLine = parsedFileIncludeHeader.value;
timeFormat = parsedFileTimeFormat.value;
timezoneFormat = parsedFileTimezoneFormat.value;
amountFormat = parsedFileAmountFormat.value;
geoLocationSeparator = parsedFileGeoLocationSeparator.value;
geoLocationOrder = parsedFileGeoLocationOrder.value;
tagSeparator = parsedFileTagSeparator.value;
hasHeaderLine = parsedFileDataColumnMapping.value.includeHeader;
timeFormat = parsedFileDataColumnMapping.value.timeFormat;
timezoneFormat = parsedFileDataColumnMapping.value.timezoneFormat;
amountFormat = parsedFileDataColumnMapping.value.amountFormat;
geoLocationSeparator = parsedFileDataColumnMapping.value.geoLocationSeparator;
geoLocationOrder = parsedFileDataColumnMapping.value.geoLocationOrder;
tagSeparator = parsedFileDataColumnMapping.value.tagSeparator;
if (!columnMapping
|| !isNumber(columnMapping[ImportTransactionColumnType.TransactionTime.type])
@@ -2134,15 +2011,15 @@ function parseData(): void {
return;
}
if (!parsedFileTimeFormat.value) {
if (!parsedFileDataColumnMapping.value.timeFormat) {
timeFormat = parsedFileAutoDetectedTimeFormat.value;
}
if (!parsedFileTimezoneFormat.value) {
if (!parsedFileDataColumnMapping.value.timezoneFormat) {
timezoneFormat = parsedFileAutoDetectedTimezoneFormat.value;
}
if (!parsedFileAmountFormat.value) {
if (!parsedFileDataColumnMapping.value.amountFormat) {
amountFormat = parsedFileAutoDetectedAmountFormat.value;
}