diff --git a/pkg/api/data_managements.go b/pkg/api/data_managements.go index c9a5442e..1688de09 100644 --- a/pkg/api/data_managements.go +++ b/pkg/api/data_managements.go @@ -20,16 +20,16 @@ const pageCountForDataExport = 1000 // DataManagementsApi represents data management api type DataManagementsApi struct { ApiUsingConfig - ezBookKeepingCsvExporter *converters.EzBookKeepingCSVFileConverter - ezBookKeepingTsvExporter *converters.EzBookKeepingTSVFileConverter - tokens *services.TokenService - users *services.UserService - accounts *services.AccountService - transactions *services.TransactionService - categories *services.TransactionCategoryService - tags *services.TransactionTagService - pictures *services.TransactionPictureService - templates *services.TransactionTemplateService + ezBookKeepingCsvConverter converters.TransactionDataConverter + ezBookKeepingTsvConverter converters.TransactionDataConverter + tokens *services.TokenService + users *services.UserService + accounts *services.AccountService + transactions *services.TransactionService + categories *services.TransactionCategoryService + tags *services.TransactionTagService + pictures *services.TransactionPictureService + templates *services.TransactionTemplateService } // Initialize a data management api singleton instance @@ -38,16 +38,16 @@ var ( ApiUsingConfig: ApiUsingConfig{ container: settings.Container, }, - ezBookKeepingCsvExporter: &converters.EzBookKeepingCSVFileConverter{}, - ezBookKeepingTsvExporter: &converters.EzBookKeepingTSVFileConverter{}, - tokens: services.Tokens, - users: services.Users, - accounts: services.Accounts, - transactions: services.Transactions, - categories: services.TransactionCategories, - tags: services.TransactionTags, - pictures: services.TransactionPictures, - templates: services.TransactionTemplates, + ezBookKeepingCsvConverter: converters.EzBookKeepingTransactionDataCSVFileConverter, + ezBookKeepingTsvConverter: converters.EzBookKeepingTransactionDataTSVFileConverter, + tokens: services.Tokens, + users: services.Users, + accounts: services.Accounts, + transactions: services.Transactions, + categories: services.TransactionCategories, + tags: services.TransactionTags, + pictures: services.TransactionPictures, + templates: services.TransactionTemplates, } ) @@ -247,12 +247,12 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType return nil, "", errs.ErrOperationFailed } - var dataExporter converters.DataConverter + var dataExporter converters.TransactionDataExporter if fileType == "tsv" { - dataExporter = a.ezBookKeepingTsvExporter + dataExporter = a.ezBookKeepingTsvConverter } else { - dataExporter = a.ezBookKeepingCsvExporter + dataExporter = a.ezBookKeepingCsvConverter } result, err := dataExporter.ToExportedContent(uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes) diff --git a/pkg/cli/user_data.go b/pkg/cli/user_data.go index f2ea8120..b39754b3 100644 --- a/pkg/cli/user_data.go +++ b/pkg/cli/user_data.go @@ -20,16 +20,16 @@ const pageCountForDataExport = 1000 // UserDataCli represents user data cli type UserDataCli struct { CliUsingConfig - ezBookKeepingCsvExporter *converters.EzBookKeepingCSVFileConverter - ezBookKeepingTsvExporter *converters.EzBookKeepingTSVFileConverter - accounts *services.AccountService - transactions *services.TransactionService - categories *services.TransactionCategoryService - tags *services.TransactionTagService - users *services.UserService - twoFactorAuthorizations *services.TwoFactorAuthorizationService - tokens *services.TokenService - forgetPasswords *services.ForgetPasswordService + ezBookKeepingCsvConverter converters.TransactionDataConverter + ezBookKeepingTsvConverter converters.TransactionDataConverter + accounts *services.AccountService + transactions *services.TransactionService + categories *services.TransactionCategoryService + tags *services.TransactionTagService + users *services.UserService + twoFactorAuthorizations *services.TwoFactorAuthorizationService + tokens *services.TokenService + forgetPasswords *services.ForgetPasswordService } // Initialize an user data cli singleton instance @@ -38,16 +38,16 @@ var ( CliUsingConfig: CliUsingConfig{ container: settings.Container, }, - ezBookKeepingCsvExporter: &converters.EzBookKeepingCSVFileConverter{}, - ezBookKeepingTsvExporter: &converters.EzBookKeepingTSVFileConverter{}, - accounts: services.Accounts, - transactions: services.Transactions, - categories: services.TransactionCategories, - tags: services.TransactionTags, - users: services.Users, - twoFactorAuthorizations: services.TwoFactorAuthorizations, - tokens: services.Tokens, - forgetPasswords: services.ForgetPasswords, + ezBookKeepingCsvConverter: converters.EzBookKeepingTransactionDataCSVFileConverter, + ezBookKeepingTsvConverter: converters.EzBookKeepingTransactionDataTSVFileConverter, + accounts: services.Accounts, + transactions: services.Transactions, + categories: services.TransactionCategories, + tags: services.TransactionTags, + users: services.Users, + twoFactorAuthorizations: services.TwoFactorAuthorizations, + tokens: services.Tokens, + forgetPasswords: services.ForgetPasswords, } ) @@ -645,12 +645,12 @@ func (l *UserDataCli) ExportTransaction(c *core.CliContext, username string, fil return nil, err } - var dataExporter converters.DataConverter + var dataExporter converters.TransactionDataExporter if fileType == "tsv" { - dataExporter = l.ezBookKeepingTsvExporter + dataExporter = l.ezBookKeepingTsvConverter } else { - dataExporter = l.ezBookKeepingCsvExporter + dataExporter = l.ezBookKeepingCsvConverter } result, err := dataExporter.ToExportedContent(uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexesMap) @@ -669,12 +669,12 @@ func (l *UserDataCli) ImportTransaction(c *core.CliContext, username string, fil return errs.ErrUsernameIsEmpty } - var dataImporter converters.DataConverter + var dataImporter converters.TransactionDataImporter if fileType == "ezbookkeeping_csv" { - dataImporter = l.ezBookKeepingCsvExporter + dataImporter = l.ezBookKeepingCsvConverter } else if fileType == "ezbookkeeping_tsv" { - dataImporter = l.ezBookKeepingTsvExporter + dataImporter = l.ezBookKeepingTsvConverter } else { return errs.ErrImportFileTypeNotSupported } diff --git a/pkg/converters/data_table.go b/pkg/converters/data_table.go new file mode 100644 index 00000000..3cd77569 --- /dev/null +++ b/pkg/converters/data_table.go @@ -0,0 +1,37 @@ +package converters + +// ImportedDataTable defines the structure of imported data table +type ImportedDataTable interface { + // DataRowCount returns the total count of data row + DataRowCount() int + + // HeaderLineColumnNames returns the header column name list + HeaderLineColumnNames() []string + + // DataRowIterator returns the iterator of data row + DataRowIterator() ImportedDataRowIterator +} + +// ImportedDataRow defines the structure of imported data row +type ImportedDataRow interface { + // ColumnCount returns the total count of column in this data row + ColumnCount() int + + // GetData returns the data in the specified column index + GetData(columnIndex int) string +} + +// ImportedDataRowIterator defines the structure of imported data row iterator +type ImportedDataRowIterator interface { + // HasNext returns whether the iterator does not reach the end + HasNext() bool + + // Next returns the next imported data row + Next() ImportedDataRow +} + +// DataTableBuilder defines the structure of data table builder +type DataTableBuilder interface { + // AppendTransaction appends the specified transaction to data builder + AppendTransaction(data map[DataTableColumn]string) +} diff --git a/pkg/converters/data_table_transaction_data_converter.go b/pkg/converters/data_table_transaction_data_converter.go new file mode 100644 index 00000000..c3f51d43 --- /dev/null +++ b/pkg/converters/data_table_transaction_data_converter.go @@ -0,0 +1,493 @@ +package converters + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" + "github.com/mayswind/ezbookkeeping/pkg/validators" +) + +// DataTableColumn represents the data column type of data table +type DataTableColumn byte + +// Data table columns +const ( + DATA_TABLE_TRANSACTION_TIME DataTableColumn = 1 + DATA_TABLE_TRANSACTION_TIMEZONE DataTableColumn = 2 + DATA_TABLE_TRANSACTION_TYPE DataTableColumn = 3 + DATA_TABLE_CATEGORY DataTableColumn = 4 + DATA_TABLE_SUB_CATEGORY DataTableColumn = 5 + DATA_TABLE_ACCOUNT_NAME DataTableColumn = 6 + DATA_TABLE_ACCOUNT_CURRENCY DataTableColumn = 7 + DATA_TABLE_AMOUNT DataTableColumn = 8 + DATA_TABLE_RELATED_ACCOUNT_NAME DataTableColumn = 9 + DATA_TABLE_RELATED_ACCOUNT_CURRENCY DataTableColumn = 10 + DATA_TABLE_RELATED_AMOUNT DataTableColumn = 11 + DATA_TABLE_GEOGRAPHIC_LOCATION DataTableColumn = 12 + DATA_TABLE_TAGS DataTableColumn = 13 + DATA_TABLE_DESCRIPTION DataTableColumn = 14 +) + +// DataTableTransactionDataConverter defines the structure of data table importer for transaction data +type DataTableTransactionDataConverter struct { + dataColumnMapping map[DataTableColumn]string + transactionTypeMapping map[models.TransactionDbType]string + transactionTypeNameMapping map[string]models.TransactionDbType + columnSeparator string + lineSeparator string + geoLocationSeparator string + transactionTagSeparator string +} + +func (c *DataTableTransactionDataConverter) buildExportedContent(dataTableBuilder DataTableBuilder, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) error { + for i := 0; i < len(transactions); i++ { + transaction := transactions[i] + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { + continue + } + + dataRowMap := make(map[DataTableColumn]string, 15) + transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) + + dataRowMap[DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone) + dataRowMap[DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(transactionTimeZone) + dataRowMap[DATA_TABLE_TRANSACTION_TYPE] = c.replaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type)) + dataRowMap[DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(transaction.CategoryId, categoryMap) + dataRowMap[DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(transaction.CategoryId, categoryMap) + dataRowMap[DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(transaction.AccountId, accountMap) + dataRowMap[DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(transaction.AccountId, accountMap) + dataRowMap[DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount) + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + dataRowMap[DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(transaction.RelatedAccountId, accountMap) + dataRowMap[DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(transaction.RelatedAccountId, accountMap) + dataRowMap[DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount) + } + + dataRowMap[DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction) + dataRowMap[DATA_TABLE_TAGS] = c.getExportedTags(transaction.TransactionId, allTagIndexes, tagMap) + dataRowMap[DATA_TABLE_DESCRIPTION] = c.replaceDelimiters(transaction.Comment) + + dataTableBuilder.AppendTransaction(dataRowMap) + } + + return nil +} + +func (c *DataTableTransactionDataConverter) parseImportedData(user *models.User, dataTable ImportedDataTable, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { + if dataTable.DataRowCount() < 1 { + return nil, nil, nil, nil, errs.ErrOperationFailed + } + + headerLineItems := dataTable.HeaderLineColumnNames() + headerItemMap := make(map[string]int) + + for i := 0; i < len(headerLineItems); i++ { + headerItemMap[headerLineItems[i]] = i + } + + timeColumnIdx, timeColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_TRANSACTION_TIME]] + timezoneColumnIdx, timezoneColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_TRANSACTION_TIMEZONE]] + typeColumnIdx, typeColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_TRANSACTION_TYPE]] + subCategoryColumnIdx, subCategoryColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_SUB_CATEGORY]] + accountColumnIdx, accountColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_ACCOUNT_NAME]] + accountCurrencyColumnIdx, accountCurrencyColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_ACCOUNT_CURRENCY]] + amountColumnIdx, amountColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_AMOUNT]] + account2ColumnIdx, account2ColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_RELATED_ACCOUNT_NAME]] + account2CurrencyColumnIdx, account2CurrencyColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_RELATED_ACCOUNT_CURRENCY]] + amount2ColumnIdx, amount2ColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_RELATED_AMOUNT]] + geoLocationIdx, geoLocationExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_GEOGRAPHIC_LOCATION]] + tagsColumnIdx, tagsColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_TAGS]] + descriptionColumnIdx, descriptionColumnExists := headerItemMap[c.dataColumnMapping[DATA_TABLE_DESCRIPTION]] + + if !timeColumnExists || !typeColumnExists || !subCategoryColumnExists || + !accountColumnExists || !amountColumnExists || !account2ColumnExists || !amount2ColumnExists { + return nil, nil, nil, nil, errs.ErrFormatInvalid + } + + if accountMap == nil { + accountMap = make(map[string]*models.Account) + } + + if categoryMap == nil { + categoryMap = make(map[string]*models.TransactionCategory) + } + + if tagMap == nil { + tagMap = make(map[string]*models.TransactionTag) + } + + allNewTransactions := make(ImportedTransactionSlice, 0, dataTable.DataRowCount()) + allNewAccounts := make([]*models.Account, 0) + allNewSubCategories := make([]*models.TransactionCategory, 0) + allNewTags := make([]*models.TransactionTag, 0) + + dataRowIterator := dataTable.DataRowIterator() + + for dataRowIterator.HasNext() { + dataRow := dataRowIterator.Next() + columnCount := dataRow.ColumnCount() + + if columnCount < 1 { + continue + } + + if columnCount < len(headerLineItems) { + return nil, nil, nil, nil, errs.ErrFormatInvalid + } + + timezoneOffset := defaultTimezoneOffset + + if timezoneColumnExists { + transactionTimezone, err := utils.ParseFromTimezoneOffset(dataRow.GetData(timezoneColumnIdx)) + + if err != nil { + return nil, nil, nil, nil, err + } + + timezoneOffset = utils.GetTimezoneOffsetMinutes(transactionTimezone) + } + + transactionTime, err := utils.ParseFromLongDateTime(dataRow.GetData(timeColumnIdx), timezoneOffset) + + if err != nil { + return nil, nil, nil, nil, err + } + + transactionDbType, err := c.getTransactionDbType(dataRow.GetData(typeColumnIdx)) + + if err != nil { + return nil, nil, nil, nil, err + } + + categoryId := int64(0) + + if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { + transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType) + + if err != nil { + return nil, nil, nil, nil, err + } + + subCategoryName := dataRow.GetData(subCategoryColumnIdx) + + if subCategoryName == "" { + return nil, nil, nil, nil, errs.ErrFormatInvalid + } + + subCategory, exists := categoryMap[subCategoryName] + + if !exists { + subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType) + allNewSubCategories = append(allNewSubCategories, subCategory) + categoryMap[subCategoryName] = subCategory + } + + categoryId = subCategory.CategoryId + } + + accountName := dataRow.GetData(accountColumnIdx) + + if accountName == "" { + return nil, nil, nil, nil, errs.ErrFormatInvalid + } + + account, exists := accountMap[accountName] + + if !exists { + currency := user.DefaultCurrency + + if accountCurrencyColumnExists { + currency = dataRow.GetData(accountCurrencyColumnIdx) + + if _, ok := validators.AllCurrencyNames[currency]; !ok { + return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid + } + } + + account = c.createNewAccountModel(user.Uid, accountName, currency) + allNewAccounts = append(allNewAccounts, account) + accountMap[accountName] = account + } + + if accountCurrencyColumnExists { + if account.Currency != dataRow.GetData(accountCurrencyColumnIdx) { + return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid + } + } + + amount, err := utils.ParseAmount(dataRow.GetData(amountColumnIdx)) + + if err != nil { + return nil, nil, nil, nil, err + } + + relatedAccountId := int64(0) + relatedAccountAmount := int64(0) + + if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + account2Name := dataRow.GetData(account2ColumnIdx) + + if account2Name == "" { + return nil, nil, nil, nil, errs.ErrFormatInvalid + } + + account2, exists := accountMap[account2Name] + + if !exists { + currency := user.DefaultCurrency + + if accountCurrencyColumnExists { + currency = dataRow.GetData(account2CurrencyColumnIdx) + + if _, ok := validators.AllCurrencyNames[currency]; !ok { + return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid + } + } + + account2 = c.createNewAccountModel(user.Uid, account2Name, currency) + allNewAccounts = append(allNewAccounts, account2) + accountMap[account2Name] = account2 + } + + if account2CurrencyColumnExists { + if account2.Currency != dataRow.GetData(account2CurrencyColumnIdx) { + return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid + } + } + + relatedAccountId = account2.AccountId + relatedAccountAmount, err = utils.ParseAmount(dataRow.GetData(amount2ColumnIdx)) + + if err != nil { + return nil, nil, nil, nil, err + } + } + + geoLongitude := float64(0) + geoLatitude := float64(0) + + if geoLocationExists { + geoLocationItems := strings.Split(dataRow.GetData(geoLocationIdx), c.geoLocationSeparator) + + if len(geoLocationItems) == 2 { + geoLongitude, err = utils.StringToFloat64(geoLocationItems[0]) + + if err != nil { + return nil, nil, nil, nil, err + } + + geoLatitude, err = utils.StringToFloat64(geoLocationItems[1]) + + if err != nil { + return nil, nil, nil, nil, err + } + } + } + + if tagsColumnExists { + tagNames := strings.Split(dataRow.GetData(tagsColumnIdx), c.transactionTagSeparator) + + for i := 0; i < len(tagNames); i++ { + tagName := tagNames[i] + + if tagName == "" { + continue + } + + tag, exists := tagMap[tagName] + + if !exists { + tag = c.createNewTransactionTagModel(user.Uid, tagName) + allNewTags = append(allNewTags, tag) + tagMap[tagName] = tag + } + } + } + + description := "" + + if descriptionColumnExists { + description = dataRow.GetData(descriptionColumnIdx) + } + + transaction := &models.Transaction{ + Uid: user.Uid, + Type: transactionDbType, + CategoryId: categoryId, + TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()), + TimezoneUtcOffset: timezoneOffset, + AccountId: account.AccountId, + Amount: amount, + HideAmount: false, + RelatedAccountId: relatedAccountId, + RelatedAccountAmount: relatedAccountAmount, + Comment: description, + GeoLongitude: geoLongitude, + GeoLatitude: geoLatitude, + CreatedIp: "127.0.0.1", + } + + allNewTransactions = append(allNewTransactions, transaction) + } + + sort.Sort(allNewTransactions) + + return allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, nil +} + +func (c *DataTableTransactionDataConverter) getTransactionDbType(transactionTypeName string) (models.TransactionDbType, error) { + transactionType, exists := c.transactionTypeNameMapping[transactionTypeName] + + if !exists { + return 0, errs.ErrTransactionTypeInvalid + } + + return transactionType, nil +} + +func (c *DataTableTransactionDataConverter) getTransactionCategoryType(transactionType models.TransactionDbType) (models.TransactionCategoryType, error) { + if transactionType == models.TRANSACTION_DB_TYPE_INCOME { + return models.CATEGORY_TYPE_INCOME, nil + } else if transactionType == models.TRANSACTION_DB_TYPE_EXPENSE { + return models.CATEGORY_TYPE_EXPENSE, nil + } else if transactionType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + return models.CATEGORY_TYPE_TRANSFER, nil + } else { + return 0, errs.ErrTransactionTypeInvalid + } +} + +func (c *DataTableTransactionDataConverter) getDisplayTransactionTypeName(transactionDbType models.TransactionDbType) string { + transactionTypeName, exists := c.transactionTypeMapping[transactionDbType] + + if !exists { + return "" + } + + return transactionTypeName +} + +func (c *DataTableTransactionDataConverter) getExportedTransactionCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string { + category, exists := categoryMap[categoryId] + + if !exists { + return "" + } + + if category.ParentCategoryId == 0 { + return c.replaceDelimiters(category.Name) + } + + parentCategory, exists := categoryMap[category.ParentCategoryId] + + if !exists { + return "" + } + + return c.replaceDelimiters(parentCategory.Name) +} + +func (c *DataTableTransactionDataConverter) getExportedTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string { + category, exists := categoryMap[categoryId] + + if exists { + return c.replaceDelimiters(category.Name) + } else { + return "" + } +} + +func (c *DataTableTransactionDataConverter) getExportedAccountName(accountId int64, accountMap map[int64]*models.Account) string { + account, exists := accountMap[accountId] + + if exists { + return c.replaceDelimiters(account.Name) + } else { + return "" + } +} + +func (c *DataTableTransactionDataConverter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string { + account, exists := accountMap[accountId] + + if exists { + return c.replaceDelimiters(account.Currency) + } else { + return "" + } +} + +func (c *DataTableTransactionDataConverter) getExportedGeographicLocation(transaction *models.Transaction) string { + if transaction.GeoLongitude != 0 || transaction.GeoLatitude != 0 { + return fmt.Sprintf("%f%s%f", transaction.GeoLongitude, c.geoLocationSeparator, transaction.GeoLatitude) + } + + return "" +} + +func (c *DataTableTransactionDataConverter) getExportedTags(transactionId int64, allTagIndexes map[int64][]int64, tagMap map[int64]*models.TransactionTag) string { + tagIndexes, exists := allTagIndexes[transactionId] + + if !exists { + return "" + } + + var ret strings.Builder + + for i := 0; i < len(tagIndexes); i++ { + tagIndex := tagIndexes[i] + tag, exists := tagMap[tagIndex] + + if !exists { + continue + } + + if ret.Len() > 0 { + ret.WriteString(c.transactionTagSeparator) + } + + ret.WriteString(strings.Replace(tag.Name, c.transactionTagSeparator, " ", -1)) + } + + return c.replaceDelimiters(ret.String()) +} + +func (c *DataTableTransactionDataConverter) replaceDelimiters(text string) string { + text = strings.Replace(text, "\r\n", " ", -1) + text = strings.Replace(text, "\r", " ", -1) + text = strings.Replace(text, "\n", " ", -1) + text = strings.Replace(text, c.columnSeparator, " ", -1) + text = strings.Replace(text, c.lineSeparator, " ", -1) + + return text +} + +func (c *DataTableTransactionDataConverter) createNewAccountModel(uid int64, accountName string, currency string) *models.Account { + return &models.Account{ + Uid: uid, + Name: accountName, + Currency: currency, + } +} + +func (c *DataTableTransactionDataConverter) createNewTransactionCategoryModel(uid int64, categoryName string, transactionCategoryType models.TransactionCategoryType) *models.TransactionCategory { + return &models.TransactionCategory{ + Uid: uid, + Name: categoryName, + Type: transactionCategoryType, + } +} + +func (c *DataTableTransactionDataConverter) createNewTransactionTagModel(uid int64, tagName string) *models.TransactionTag { + return &models.TransactionTag{ + Uid: uid, + Name: tagName, + } +} diff --git a/pkg/converters/ezbookkeeping_csv_file.go b/pkg/converters/ezbookkeeping_csv_file.go deleted file mode 100644 index a1888a1d..00000000 --- a/pkg/converters/ezbookkeeping_csv_file.go +++ /dev/null @@ -1,22 +0,0 @@ -package converters - -import ( - "github.com/mayswind/ezbookkeeping/pkg/models" -) - -// EzBookKeepingCSVFileConverter defines the structure of CSV file converter -type EzBookKeepingCSVFileConverter struct { - EzBookKeepingPlainFileConverter -} - -const csvSeparator = "," - -// ToExportedContent returns the exported CSV data -func (e *EzBookKeepingCSVFileConverter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) { - return e.toExportedContent(uid, csvSeparator, transactions, accountMap, categoryMap, tagMap, allTagIndexes) -} - -// ParseImportedData parses transactions of ezbookkeeping CSV data -func (e *EzBookKeepingCSVFileConverter) ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { - return e.parseImportedData(user, csvSeparator, data, defaultTimezoneOffset, accountMap, categoryMap, tagMap) -} diff --git a/pkg/converters/ezbookkeeping_plain_file.go b/pkg/converters/ezbookkeeping_plain_file.go deleted file mode 100644 index d87f856f..00000000 --- a/pkg/converters/ezbookkeeping_plain_file.go +++ /dev/null @@ -1,491 +0,0 @@ -package converters - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/mayswind/ezbookkeeping/pkg/errs" - "github.com/mayswind/ezbookkeeping/pkg/models" - "github.com/mayswind/ezbookkeeping/pkg/utils" - "github.com/mayswind/ezbookkeeping/pkg/validators" -) - -// EzBookKeepingPlainFileConverter defines the structure of plain file converter -type EzBookKeepingPlainFileConverter struct { -} - -const lineSeparator = "\n" -const geoLocationSeparator = " " -const transactionTagSeparator = ";" -const headerLine = "Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description" + lineSeparator -const dataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" + lineSeparator - -// toExportedContent returns the exported plain data -func (e *EzBookKeepingPlainFileConverter) toExportedContent(uid int64, separator string, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) { - var ret strings.Builder - - ret.Grow(len(transactions) * 100) - - actualHeaderLine := headerLine - actualDataLineFormat := dataLineFormat - - if separator != "," { - actualHeaderLine = strings.Replace(headerLine, ",", separator, -1) - actualDataLineFormat = strings.Replace(dataLineFormat, ",", separator, -1) - } - - ret.WriteString(actualHeaderLine) - - for i := 0; i < len(transactions); i++ { - transaction := transactions[i] - - if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { - continue - } - - transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60) - transactionTime := utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone) - transactionTimezone := utils.FormatTimezoneOffset(transactionTimeZone) - transactionType := e.getTransactionTypeName(transaction.Type) - category := e.replaceDelimiters(e.getTransactionCategoryName(transaction.CategoryId, categoryMap), separator) - subCategory := e.replaceDelimiters(e.getTransactionSubCategoryName(transaction.CategoryId, categoryMap), separator) - account := e.replaceDelimiters(e.getAccountName(transaction.AccountId, accountMap), separator) - accountCurrency := e.getAccountCurrency(transaction.AccountId, accountMap) - amount := utils.FormatAmount(transaction.Amount) - account2 := "" - account2Currency := "" - account2Amount := "" - geoLocation := "" - - if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { - account2 = e.replaceDelimiters(e.getAccountName(transaction.RelatedAccountId, accountMap), separator) - account2Currency = e.getAccountCurrency(transaction.RelatedAccountId, accountMap) - account2Amount = utils.FormatAmount(transaction.RelatedAccountAmount) - } - - if transaction.GeoLongitude != 0 || transaction.GeoLatitude != 0 { - geoLocation = fmt.Sprintf("%f%s%f", transaction.GeoLongitude, geoLocationSeparator, transaction.GeoLatitude) - } - - tags := e.replaceDelimiters(e.getTags(transaction.TransactionId, allTagIndexes, tagMap), separator) - comment := e.replaceDelimiters(transaction.Comment, separator) - - ret.WriteString(fmt.Sprintf(actualDataLineFormat, transactionTime, transactionTimezone, transactionType, category, subCategory, account, accountCurrency, amount, account2, account2Currency, account2Amount, geoLocation, tags, comment)) - } - - return []byte(ret.String()), nil -} - -func (e *EzBookKeepingPlainFileConverter) parseImportedData(user *models.User, separator string, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { - lines := strings.Split(string(data), lineSeparator) - - if len(lines) < 2 { - return nil, nil, nil, nil, errs.ErrOperationFailed - } - - headerLineItems := strings.Split(lines[0], separator) - headerItemMap := make(map[string]int) - - for i := 0; i < len(headerLineItems); i++ { - headerItemMap[headerLineItems[i]] = i - } - - timeColumnIdx, timeColumnExists := headerItemMap["Time"] - timezoneColumnIdx, timezoneColumnExists := headerItemMap["Timezone"] - typeColumnIdx, typeColumnExists := headerItemMap["Type"] - subCategoryColumnIdx, subCategoryColumnExists := headerItemMap["Sub Category"] - accountColumnIdx, accountColumnExists := headerItemMap["Account"] - accountCurrencyColumnIdx, accountCurrencyColumnExists := headerItemMap["Account Currency"] - amountColumnIdx, amountColumnExists := headerItemMap["Amount"] - account2ColumnIdx, account2ColumnExists := headerItemMap["Account2"] - account2CurrencyColumnIdx, account2CurrencyColumnExists := headerItemMap["Account2 Currency"] - amount2ColumnIdx, amount2ColumnExists := headerItemMap["Account2 Amount"] - geoLocationIdx, geoLocationExists := headerItemMap["Geographic Location"] - tagsColumnIdx, tagsColumnExists := headerItemMap["Tags"] - descriptionColumnIdx, descriptionColumnExists := headerItemMap["Description"] - - if !timeColumnExists || !typeColumnExists || !subCategoryColumnExists || - !accountColumnExists || !amountColumnExists || !account2ColumnExists || !amount2ColumnExists { - return nil, nil, nil, nil, errs.ErrFormatInvalid - } - - if accountMap == nil { - accountMap = make(map[string]*models.Account) - } - - if categoryMap == nil { - categoryMap = make(map[string]*models.TransactionCategory) - } - - if tagMap == nil { - tagMap = make(map[string]*models.TransactionTag) - } - - allNewTransactions := make(ImportTransactionSlice, 0, len(lines)) - allNewAccounts := make([]*models.Account, 0) - allNewSubCategories := make([]*models.TransactionCategory, 0) - allNewTags := make([]*models.TransactionTag, 0) - - for i := 1; i < len(lines); i++ { - line := lines[i] - - if len(line) < 1 { - continue - } - - lineItems := strings.Split(line, separator) - - if len(lineItems) < len(headerLineItems) { - return nil, nil, nil, nil, errs.ErrFormatInvalid - } - - timezoneOffset := defaultTimezoneOffset - - if timezoneColumnExists { - transactionTimezone, err := utils.ParseFromTimezoneOffset(lineItems[timezoneColumnIdx]) - - if err != nil { - return nil, nil, nil, nil, err - } - - timezoneOffset = utils.GetTimezoneOffsetMinutes(transactionTimezone) - } - - transactionTime, err := utils.ParseFromLongDateTime(lineItems[timeColumnIdx], timezoneOffset) - - if err != nil { - return nil, nil, nil, nil, err - } - - transactionDbType, err := e.getTransactionDbType(lineItems[typeColumnIdx]) - - if err != nil { - return nil, nil, nil, nil, err - } - - categoryId := int64(0) - - if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { - transactionCategoryType, err := e.getTransactionCategoryType(transactionDbType) - - if err != nil { - return nil, nil, nil, nil, err - } - - subCategoryName := lineItems[subCategoryColumnIdx] - - if subCategoryName == "" { - return nil, nil, nil, nil, errs.ErrFormatInvalid - } - - subCategory, exists := categoryMap[subCategoryName] - - if !exists { - subCategory = e.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType) - allNewSubCategories = append(allNewSubCategories, subCategory) - categoryMap[subCategoryName] = subCategory - } - - categoryId = subCategory.CategoryId - } - - accountName := lineItems[accountColumnIdx] - - if accountName == "" { - return nil, nil, nil, nil, errs.ErrFormatInvalid - } - - account, exists := accountMap[accountName] - - if !exists { - currency := user.DefaultCurrency - - if accountCurrencyColumnExists { - currency = lineItems[accountCurrencyColumnIdx] - - if _, ok := validators.AllCurrencyNames[currency]; !ok { - return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid - } - } - - account = e.createNewAccountModel(user.Uid, accountName, currency) - allNewAccounts = append(allNewAccounts, account) - accountMap[accountName] = account - } - - if accountCurrencyColumnExists { - if account.Currency != lineItems[accountCurrencyColumnIdx] { - return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid - } - } - - amount, err := utils.ParseAmount(lineItems[amountColumnIdx]) - - if err != nil { - return nil, nil, nil, nil, err - } - - relatedAccountId := int64(0) - relatedAccountAmount := int64(0) - - if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { - account2Name := lineItems[account2ColumnIdx] - - if account2Name == "" { - return nil, nil, nil, nil, errs.ErrFormatInvalid - } - - account2, exists := accountMap[account2Name] - - if !exists { - currency := user.DefaultCurrency - - if accountCurrencyColumnExists { - currency = lineItems[account2CurrencyColumnIdx] - - if _, ok := validators.AllCurrencyNames[currency]; !ok { - return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid - } - } - - account2 = e.createNewAccountModel(user.Uid, account2Name, currency) - allNewAccounts = append(allNewAccounts, account2) - accountMap[account2Name] = account2 - } - - if account2CurrencyColumnExists { - if account2.Currency != lineItems[account2CurrencyColumnIdx] { - return nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid - } - } - - relatedAccountId = account2.AccountId - relatedAccountAmount, err = utils.ParseAmount(lineItems[amount2ColumnIdx]) - - if err != nil { - return nil, nil, nil, nil, err - } - } - - geoLongitude := float64(0) - geoLatitude := float64(0) - - if geoLocationExists { - geoLocationItems := strings.Split(lineItems[geoLocationIdx], geoLocationSeparator) - - if len(geoLocationItems) == 2 { - geoLongitude, err = utils.StringToFloat64(geoLocationItems[0]) - - if err != nil { - return nil, nil, nil, nil, err - } - - geoLatitude, err = utils.StringToFloat64(geoLocationItems[1]) - - if err != nil { - return nil, nil, nil, nil, err - } - } - } - - if tagsColumnExists { - tagNames := strings.Split(lineItems[tagsColumnIdx], transactionTagSeparator) - - for i := 0; i < len(tagNames); i++ { - tagName := tagNames[i] - - if tagName == "" { - continue - } - - tag, exists := tagMap[tagName] - - if !exists { - tag = e.createNewTransactionTagModel(user.Uid, tagName) - allNewTags = append(allNewTags, tag) - tagMap[tagName] = tag - } - } - } - - description := "" - - if descriptionColumnExists { - description = lineItems[descriptionColumnIdx] - } - - transaction := &models.Transaction{ - Uid: user.Uid, - Type: transactionDbType, - CategoryId: categoryId, - TransactionTime: utils.GetMinTransactionTimeFromUnixTime(transactionTime.Unix()), - TimezoneUtcOffset: timezoneOffset, - AccountId: account.AccountId, - Amount: amount, - HideAmount: false, - RelatedAccountId: relatedAccountId, - RelatedAccountAmount: relatedAccountAmount, - Comment: description, - GeoLongitude: geoLongitude, - GeoLatitude: geoLatitude, - CreatedIp: "127.0.0.1", - } - - allNewTransactions = append(allNewTransactions, transaction) - } - - sort.Sort(allNewTransactions) - - return allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, nil -} - -func (e *EzBookKeepingPlainFileConverter) getTransactionTypeName(transactionDbType models.TransactionDbType) string { - if transactionDbType == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { - return "Balance Modification" - } else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME { - return "Income" - } else if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE { - return "Expense" - } else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_IN { - return "Transfer" - } else { - return "" - } -} - -func (e *EzBookKeepingPlainFileConverter) getTransactionDbType(transactionTypeName string) (models.TransactionDbType, error) { - if transactionTypeName == "Balance Modification" { - return models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, nil - } else if transactionTypeName == "Income" { - return models.TRANSACTION_DB_TYPE_INCOME, nil - } else if transactionTypeName == "Expense" { - return models.TRANSACTION_DB_TYPE_EXPENSE, nil - } else if transactionTypeName == "Transfer" { - return models.TRANSACTION_DB_TYPE_TRANSFER_OUT, nil - } else { - return 0, errs.ErrTransactionTypeInvalid - } -} - -func (e *EzBookKeepingPlainFileConverter) getTransactionCategoryType(transactionType models.TransactionDbType) (models.TransactionCategoryType, error) { - if transactionType == models.TRANSACTION_DB_TYPE_INCOME { - return models.CATEGORY_TYPE_INCOME, nil - } else if transactionType == models.TRANSACTION_DB_TYPE_EXPENSE { - return models.CATEGORY_TYPE_EXPENSE, nil - } else if transactionType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { - return models.CATEGORY_TYPE_TRANSFER, nil - } else { - return 0, errs.ErrTransactionTypeInvalid - } -} - -func (e *EzBookKeepingPlainFileConverter) getTransactionCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string { - category, exists := categoryMap[categoryId] - - if !exists { - return "" - } - - if category.ParentCategoryId == 0 { - return category.Name - } - - parentCategory, exists := categoryMap[category.ParentCategoryId] - - if !exists { - return "" - } - - return parentCategory.Name -} - -func (e *EzBookKeepingPlainFileConverter) getTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string { - category, exists := categoryMap[categoryId] - - if exists { - return category.Name - } else { - return "" - } -} - -func (e *EzBookKeepingPlainFileConverter) getAccountName(accountId int64, accountMap map[int64]*models.Account) string { - account, exists := accountMap[accountId] - - if exists { - return account.Name - } else { - return "" - } -} - -func (e *EzBookKeepingPlainFileConverter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string { - account, exists := accountMap[accountId] - - if exists { - return account.Currency - } else { - return "" - } -} - -func (e *EzBookKeepingPlainFileConverter) getTags(transactionId int64, allTagIndexes map[int64][]int64, tagMap map[int64]*models.TransactionTag) string { - tagIndexes, exists := allTagIndexes[transactionId] - - if !exists { - return "" - } - - var ret strings.Builder - - for i := 0; i < len(tagIndexes); i++ { - tagIndex := tagIndexes[i] - tag, exists := tagMap[tagIndex] - - if !exists { - continue - } - - if ret.Len() > 0 { - ret.WriteString(transactionTagSeparator) - } - - ret.WriteString(strings.Replace(tag.Name, transactionTagSeparator, " ", -1)) - } - - return ret.String() -} - -func (e *EzBookKeepingPlainFileConverter) replaceDelimiters(text string, separator string) string { - text = strings.Replace(text, separator, " ", -1) - text = strings.Replace(text, "\r\n", " ", -1) - text = strings.Replace(text, "\r", " ", -1) - text = strings.Replace(text, "\n", " ", -1) - - return text -} - -func (e *EzBookKeepingPlainFileConverter) createNewAccountModel(uid int64, accountName string, currency string) *models.Account { - return &models.Account{ - Uid: uid, - Name: accountName, - Currency: currency, - } -} - -func (e *EzBookKeepingPlainFileConverter) createNewTransactionCategoryModel(uid int64, categoryName string, transactionCategoryType models.TransactionCategoryType) *models.TransactionCategory { - return &models.TransactionCategory{ - Uid: uid, - Name: categoryName, - Type: transactionCategoryType, - } -} - -func (e *EzBookKeepingPlainFileConverter) createNewTransactionTagModel(uid int64, tagName string) *models.TransactionTag { - return &models.TransactionTag{ - Uid: uid, - Name: tagName, - } -} diff --git a/pkg/converters/ezbookkeeping_transaction_data_csv_file_converter.go b/pkg/converters/ezbookkeeping_transaction_data_csv_file_converter.go new file mode 100644 index 00000000..82f56898 --- /dev/null +++ b/pkg/converters/ezbookkeeping_transaction_data_csv_file_converter.go @@ -0,0 +1,24 @@ +package converters + +// ezBookKeepingTransactionDataCSVFileConverter defines the structure of CSV file converter +type ezBookKeepingTransactionDataCSVFileConverter struct { + ezBookKeepingTransactionDataPlainTextConverter +} + +// Initialize an ezbookkeeping transaction data csv file converter singleton instance +var ( + EzBookKeepingTransactionDataCSVFileConverter = &ezBookKeepingTransactionDataCSVFileConverter{ + ezBookKeepingTransactionDataPlainTextConverter{ + DataTableTransactionDataConverter: DataTableTransactionDataConverter{ + dataColumnMapping: ezbookkeepingDataColumnNameMapping, + transactionTypeMapping: ezbookkeepingTransactionTypeNameMapping, + transactionTypeNameMapping: ezbookkeepingNameTransactionTypeMapping, + columnSeparator: ",", + lineSeparator: "\n", + geoLocationSeparator: " ", + transactionTagSeparator: ";", + }, + columns: ezbookkeepingDataColumns, + }, + } +) diff --git a/pkg/converters/ezbookkeeping_transaction_data_plain_text_converter.go b/pkg/converters/ezbookkeeping_transaction_data_plain_text_converter.go new file mode 100644 index 00000000..aa6249e7 --- /dev/null +++ b/pkg/converters/ezbookkeeping_transaction_data_plain_text_converter.go @@ -0,0 +1,82 @@ +package converters + +import ( + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +// ezBookKeepingTransactionDataPlainTextConverter defines the structure of plain file converter for transaction data +type ezBookKeepingTransactionDataPlainTextConverter struct { + DataTableTransactionDataConverter + columns []DataTableColumn +} + +var ezbookkeepingDataColumnNameMapping = map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: "Time", + DATA_TABLE_TRANSACTION_TIMEZONE: "Timezone", + DATA_TABLE_TRANSACTION_TYPE: "Type", + DATA_TABLE_CATEGORY: "Category", + DATA_TABLE_SUB_CATEGORY: "Sub Category", + DATA_TABLE_ACCOUNT_NAME: "Account", + DATA_TABLE_ACCOUNT_CURRENCY: "Account Currency", + DATA_TABLE_AMOUNT: "Amount", + DATA_TABLE_RELATED_ACCOUNT_NAME: "Account2", + DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "Account2 Currency", + DATA_TABLE_RELATED_AMOUNT: "Account2 Amount", + DATA_TABLE_GEOGRAPHIC_LOCATION: "Geographic Location", + DATA_TABLE_TAGS: "Tags", + DATA_TABLE_DESCRIPTION: "Description", +} + +var ezbookkeepingTransactionTypeNameMapping = map[models.TransactionDbType]string{ + models.TRANSACTION_DB_TYPE_MODIFY_BALANCE: "Balance Modification", + models.TRANSACTION_DB_TYPE_INCOME: "Income", + models.TRANSACTION_DB_TYPE_EXPENSE: "Expense", + models.TRANSACTION_DB_TYPE_TRANSFER_OUT: "Transfer", + models.TRANSACTION_DB_TYPE_TRANSFER_IN: "Transfer", +} + +var ezbookkeepingNameTransactionTypeMapping = map[string]models.TransactionDbType{ + "Balance Modification": models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, + "Income": models.TRANSACTION_DB_TYPE_INCOME, + "Expense": models.TRANSACTION_DB_TYPE_EXPENSE, + "Transfer": models.TRANSACTION_DB_TYPE_TRANSFER_OUT, +} + +var ezbookkeepingDataColumns = []DataTableColumn{ + DATA_TABLE_TRANSACTION_TIME, + DATA_TABLE_TRANSACTION_TIMEZONE, + DATA_TABLE_TRANSACTION_TYPE, + DATA_TABLE_CATEGORY, + DATA_TABLE_SUB_CATEGORY, + DATA_TABLE_ACCOUNT_NAME, + DATA_TABLE_ACCOUNT_CURRENCY, + DATA_TABLE_AMOUNT, + DATA_TABLE_RELATED_ACCOUNT_NAME, + DATA_TABLE_RELATED_ACCOUNT_CURRENCY, + DATA_TABLE_RELATED_AMOUNT, + DATA_TABLE_GEOGRAPHIC_LOCATION, + DATA_TABLE_TAGS, + DATA_TABLE_DESCRIPTION, +} + +// ToExportedContent returns the exported plain text transaction data +func (c *ezBookKeepingTransactionDataPlainTextConverter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) { + dataTableBuilder := createNewezbookkeepingTransactionPlainTextDataTableBuilder(len(transactions), c.columns, c.dataColumnMapping, c.columnSeparator, c.lineSeparator) + err := c.buildExportedContent(dataTableBuilder, uid, transactions, accountMap, categoryMap, tagMap, allTagIndexes) + + if err != nil { + return nil, err + } + + return []byte(dataTableBuilder.String()), nil +} + +func (c *ezBookKeepingTransactionDataPlainTextConverter) ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { + dataTable, err := createNewezbookkeepingTransactionPlainTextDataTable(string(data), c.columnSeparator, c.lineSeparator) + + if err != nil { + return nil, nil, nil, nil, err + } + + return c.parseImportedData(user, dataTable, defaultTimezoneOffset, accountMap, categoryMap, tagMap) +} diff --git a/pkg/converters/ezbookkeeping_plain_file_test.go b/pkg/converters/ezbookkeeping_transaction_data_plain_text_converter_test.go similarity index 71% rename from pkg/converters/ezbookkeeping_plain_file_test.go rename to pkg/converters/ezbookkeeping_transaction_data_plain_text_converter_test.go index c9b45a0d..d958e12f 100644 --- a/pkg/converters/ezbookkeeping_plain_file_test.go +++ b/pkg/converters/ezbookkeeping_transaction_data_plain_text_converter_test.go @@ -10,7 +10,7 @@ import ( ) func TestEzBookKeepingPlainFileConverterToExportedContent(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter transactions := make([]*models.Transaction, 3) transactions[0] = &models.Transaction{ @@ -116,21 +116,21 @@ func TestEzBookKeepingPlainFileConverterToExportedContent(t *testing.T) { "2024-09-01 12:34:56,+08:00,Income,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,123.450000 45.670000,Test Tag;Test Tag2,Hello World\n" + "2024-09-01 12:34:56,+00:00,Expense,Test Category2,Test Sub Category2,Test Account,CNY,-0.10,,,,,Test Tag,Foo#Bar\n" + "2024-09-01 12:34:56,-05:00,Transfer,Test Category3,Test Sub Category3,Test Account,CNY,123.45,Test Account2,USD,17.35,,Test Tag2,T\te s t test\n" - actualContent, err := converter.toExportedContent(123, ",", transactions, accountMap, categoryMap, tagMap, allTagIndexes) + actualContent, err := converter.ToExportedContent(123, transactions, accountMap, categoryMap, tagMap, allTagIndexes) assert.Nil(t, err) assert.Equal(t, expectedContent, string(actualContent)) } func TestEzBookKeepingPlainFileConverterParseImportedData_MinimumValidData(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 00:00:00,Balance Modification,,Test Account,123.45,,\n"+ "2024-09-01 01:23:45,Income,Test Category,Test Account,0.12,,\n"+ "2024-09-01 12:34:56,Expense,Test Category2,Test Account,1.00,,\n"+ @@ -182,44 +182,44 @@ func TestEzBookKeepingPlainFileConverterParseImportedData_MinimumValidData(t *te } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidTime(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01T12:34:56,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "09/01/2024 12:34:56,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidType(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Type,Test Category,Test Account,123.45,,"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidTimezone(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + allNewTransactions, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,+08:00,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil) assert.Nil(t, err) assert.Equal(t, 1, len(allNewTransactions)) @@ -227,57 +227,57 @@ func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidTimezone(t * } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidTimezone(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Asia/Shanghai,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidSubCategoryName(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Expense,,Test Account,123.45,,"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidAccountName(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Expense,Test Category,,123.45,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,,123.45"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidAccountCurrency(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - allNewTransactions, allNewAccounts, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ + allNewTransactions, allNewAccounts, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ "2024-09-01 01:23:45,Balance Modification,Test Category,Test Account,USD,123.45,,,\n"+ "2024-09-01 12:34:56,Transfer,Test Category2,Test Account,USD,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil) @@ -296,67 +296,67 @@ func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidAccountCurre } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ "2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+ "2024-09-01 12:34:56,Transfer,Test Category3,Test Account,CNY,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ "2024-09-01 01:23:45,Balance Modification,,Test Account,USD,123.45,,,\n"+ "2024-09-01 12:34:56,Transfer,Test Category3,Test Account2,CNY,1.23,Test Account,EUR,1.10"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseNotSupportedCurrency(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ "2024-09-01 01:23:45,Balance Modification,Test Category,Test Account,XXX,123.45,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount\n"+ "2024-09-01 01:23:45,Transfer,Test Category,Test Account,USD,123.45,Test Account2,XXX,123.45"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidAmount(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Expense,Test Category,Test Account,123 45,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+ "2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,Test Account2,123 45"), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidGeographicLocation(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ + allNewTransactions, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ "2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,123.45 45.56"), 0, nil, nil, nil) assert.Nil(t, err) @@ -366,38 +366,38 @@ func TestEzBookKeepingPlainFileConverterParseImportedData_ParseValidGeographicLo } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseInvalidGeographicLocation(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ + allNewTransactions, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ "2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,1"), 0, nil, nil, nil) assert.Nil(t, err) assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, float64(0), allNewTransactions[0].GeoLongitude) assert.Equal(t, float64(0), allNewTransactions[0].GeoLatitude) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ "2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,a b"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Geographic Location\n"+ "2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,1 "), 0, nil, nil, nil) assert.NotNil(t, err) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseTag(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - _, _, _, allNewTags, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Tags\n"+ + _, _, _, allNewTags, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Tags\n"+ "2024-09-01 00:00:00,Balance Modification,,Test Account,123.45,,,foo;;bar.;#test;hello\tworld;;"), 0, nil, nil, nil) assert.Nil(t, err) @@ -418,14 +418,14 @@ func TestEzBookKeepingPlainFileConverterParseImportedData_ParseTag(t *testing.T) } func TestEzBookKeepingPlainFileConverterParseImportedData_ParseDescription(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1234567890, DefaultCurrency: "CNY", } - allNewTransactions, _, _, _, err := converter.parseImportedData(user, ",", []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Description\n"+ + allNewTransactions, _, _, _, err := converter.ParseImportedData(user, []byte("Time,Type,Sub Category,Account,Amount,Account2,Account2 Amount,Description\n"+ "2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,,,foo bar\t#test"), 0, nil, nil, nil) assert.Nil(t, err) @@ -434,45 +434,45 @@ func TestEzBookKeepingPlainFileConverterParseImportedData_ParseDescription(t *te } func TestEzBookKeepingPlainFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) { - converter := &EzBookKeepingPlainFileConverter{} + converter := EzBookKeepingTransactionDataCSVFileConverter user := &models.User{ Uid: 1, DefaultCurrency: "CNY", } - _, _, _, _, err := converter.parseImportedData(user, ",", []byte(""), 0, nil, nil, nil) + _, _, _, _, err := converter.ParseImportedData(user, []byte(""), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Type,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Type,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Balance Modification,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Timezone,Type,Category,Sub Category,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,CNY,123.45,,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) - _, _, _, _, err = converter.parseImportedData(user, ",", []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ + _, _, _, _, err = converter.ParseImportedData(user, []byte("Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n"+ "2024-09-01 00:00:00,+08:00,Balance Modification,Test Category,Test Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil) assert.NotNil(t, err) } diff --git a/pkg/converters/ezbookkeeping_transaction_data_plain_text_data_table.go b/pkg/converters/ezbookkeeping_transaction_data_plain_text_data_table.go new file mode 100644 index 00000000..460ea1fd --- /dev/null +++ b/pkg/converters/ezbookkeeping_transaction_data_plain_text_data_table.go @@ -0,0 +1,179 @@ +package converters + +import ( + "fmt" + "strings" + + "github.com/mayswind/ezbookkeeping/pkg/errs" +) + +// ezBookKeepingTransactionPlainTextDataTable defines the structure of ezbookkeeping transaction plain text data table +type ezBookKeepingTransactionPlainTextDataTable struct { + columnSeparator string + lineSeparator string + allLines []string + headerLineColumnNames []string +} + +// ezBookKeepingTransactionPlainTextDataRow defines the structure of ezbookkeeping transaction plain text data row +type ezBookKeepingTransactionPlainTextDataRow struct { + allItems []string +} + +// ezBookKeepingTransactionPlainTextDataRowIterator defines the structure of ezbookkeeping transaction plain text data row iterator +type ezBookKeepingTransactionPlainTextDataRowIterator struct { + dataTable *ezBookKeepingTransactionPlainTextDataTable + currentIndex int +} + +// ezBookKeepingTransactionPlainTextDataTableBuilder defines the structure of ezbookkeeping transaction plain text data table builder +type ezBookKeepingTransactionPlainTextDataTableBuilder struct { + columnSeparator string + lineSeparator string + columns []DataTableColumn + dataColumnNameMapping map[DataTableColumn]string + dataLineFormat string + builder *strings.Builder +} + +// DataRowCount returns the total count of data row +func (t *ezBookKeepingTransactionPlainTextDataTable) DataRowCount() int { + if len(t.allLines) < 1 { + return 0 + } + + return len(t.allLines) - 1 +} + +// HeaderLineColumnNames returns the header column name list +func (t *ezBookKeepingTransactionPlainTextDataTable) HeaderLineColumnNames() []string { + return t.headerLineColumnNames +} + +// DataRowIterator returns the iterator of data row +func (t *ezBookKeepingTransactionPlainTextDataTable) DataRowIterator() ImportedDataRowIterator { + return &ezBookKeepingTransactionPlainTextDataRowIterator{ + dataTable: t, + currentIndex: 0, + } +} + +// ColumnCount returns the total count of column in this data row +func (r *ezBookKeepingTransactionPlainTextDataRow) ColumnCount() int { + return len(r.allItems) +} + +// GetData returns the data in the specified column index +func (r *ezBookKeepingTransactionPlainTextDataRow) GetData(columnIndex int) string { + return r.allItems[columnIndex] +} + +// HasNext returns whether the iterator does not reach the end +func (t *ezBookKeepingTransactionPlainTextDataRowIterator) HasNext() bool { + return t.currentIndex+1 < len(t.dataTable.allLines) +} + +// Next returns the next imported data row +func (t *ezBookKeepingTransactionPlainTextDataRowIterator) Next() ImportedDataRow { + if t.currentIndex+1 >= len(t.dataTable.allLines) { + return nil + } + + t.currentIndex++ + + rowContent := t.dataTable.allLines[t.currentIndex] + rowItems := strings.Split(rowContent, t.dataTable.columnSeparator) + + return &ezBookKeepingTransactionPlainTextDataRow{ + allItems: rowItems, + } +} + +// AppendTransaction appends the specified transaction to data builder +func (b *ezBookKeepingTransactionPlainTextDataTableBuilder) AppendTransaction(data map[DataTableColumn]string) { + dataRowParams := make([]any, len(b.columns)) + + for i := 0; i < len(b.columns); i++ { + dataRowParams[i] = data[b.columns[i]] + } + + b.builder.WriteString(fmt.Sprintf(b.dataLineFormat, dataRowParams...)) +} + +// String returns the textual representation of this data +func (b *ezBookKeepingTransactionPlainTextDataTableBuilder) String() string { + return b.builder.String() +} + +func (b *ezBookKeepingTransactionPlainTextDataTableBuilder) generateHeaderLine() string { + var ret strings.Builder + + for i := 0; i < len(b.columns); i++ { + if ret.Len() > 0 { + ret.WriteString(b.columnSeparator) + } + + dataColumn := b.columns[i] + columnName := b.dataColumnNameMapping[dataColumn] + + ret.WriteString(columnName) + } + + ret.WriteString(b.lineSeparator) + + return ret.String() +} + +func (b *ezBookKeepingTransactionPlainTextDataTableBuilder) generateDataLineFormat() string { + var ret strings.Builder + + for i := 0; i < len(b.columns); i++ { + if ret.Len() > 0 { + ret.WriteString(b.columnSeparator) + } + + ret.WriteString("%s") + } + + ret.WriteString(b.lineSeparator) + + return ret.String() +} + +func createNewezbookkeepingTransactionPlainTextDataTable(content string, columnSeparator string, lineSeparator string) (*ezBookKeepingTransactionPlainTextDataTable, error) { + allLines := strings.Split(content, lineSeparator) + + if len(allLines) < 2 { + return nil, errs.ErrOperationFailed + } + + headerLineItems := strings.Split(allLines[0], columnSeparator) + + return &ezBookKeepingTransactionPlainTextDataTable{ + columnSeparator: columnSeparator, + lineSeparator: lineSeparator, + allLines: allLines, + headerLineColumnNames: headerLineItems, + }, nil +} + +func createNewezbookkeepingTransactionPlainTextDataTableBuilder(transactionCount int, columns []DataTableColumn, dataColumnNameMapping map[DataTableColumn]string, columnSeparator string, lineSeparator string) *ezBookKeepingTransactionPlainTextDataTableBuilder { + var builder strings.Builder + builder.Grow(transactionCount * 100) + + dataTableBuilder := &ezBookKeepingTransactionPlainTextDataTableBuilder{ + columnSeparator: columnSeparator, + lineSeparator: lineSeparator, + columns: columns, + dataColumnNameMapping: dataColumnNameMapping, + builder: &builder, + } + + headerLine := dataTableBuilder.generateHeaderLine() + dataLineFormat := dataTableBuilder.generateDataLineFormat() + + dataTableBuilder.builder.WriteString(headerLine) + dataTableBuilder.dataLineFormat = dataLineFormat + + return dataTableBuilder +} diff --git a/pkg/converters/ezbookkeeping_transaction_data_tsv_file_converter.go b/pkg/converters/ezbookkeeping_transaction_data_tsv_file_converter.go new file mode 100644 index 00000000..c8cf3850 --- /dev/null +++ b/pkg/converters/ezbookkeeping_transaction_data_tsv_file_converter.go @@ -0,0 +1,24 @@ +package converters + +// ezBookKeepingTransactionDataTSVFileConverter defines the structure of TSV file converter +type ezBookKeepingTransactionDataTSVFileConverter struct { + ezBookKeepingTransactionDataPlainTextConverter +} + +// Initialize an ezbookkeeping transaction data tsv file converter singleton instance +var ( + EzBookKeepingTransactionDataTSVFileConverter = &ezBookKeepingTransactionDataTSVFileConverter{ + ezBookKeepingTransactionDataPlainTextConverter{ + DataTableTransactionDataConverter: DataTableTransactionDataConverter{ + dataColumnMapping: ezbookkeepingDataColumnNameMapping, + transactionTypeMapping: ezbookkeepingTransactionTypeNameMapping, + transactionTypeNameMapping: ezbookkeepingNameTransactionTypeMapping, + columnSeparator: "\t", + lineSeparator: "\n", + geoLocationSeparator: " ", + transactionTagSeparator: ";", + }, + columns: ezbookkeepingDataColumns, + }, + } +) diff --git a/pkg/converters/ezbookkeeping_tsv_file.go b/pkg/converters/ezbookkeeping_tsv_file.go deleted file mode 100644 index 3d68599e..00000000 --- a/pkg/converters/ezbookkeeping_tsv_file.go +++ /dev/null @@ -1,22 +0,0 @@ -package converters - -import ( - "github.com/mayswind/ezbookkeeping/pkg/models" -) - -// EzBookKeepingTSVFileConverter defines the structure of TSV file converter -type EzBookKeepingTSVFileConverter struct { - EzBookKeepingPlainFileConverter -} - -const tsvSeparator = "\t" - -// ToExportedContent returns the exported TSV data -func (e *EzBookKeepingTSVFileConverter) ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) { - return e.toExportedContent(uid, tsvSeparator, transactions, accountMap, categoryMap, tagMap, allTagIndexes) -} - -// ParseImportedData parses transactions of ezbookkeeping TSV data -func (e *EzBookKeepingTSVFileConverter) ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { - return e.parseImportedData(user, tsvSeparator, data, defaultTimezoneOffset, accountMap, categoryMap, tagMap) -} diff --git a/pkg/converters/import_transactions.go b/pkg/converters/imported_transactions.go similarity index 67% rename from pkg/converters/import_transactions.go rename to pkg/converters/imported_transactions.go index 3329e416..e926db52 100644 --- a/pkg/converters/import_transactions.go +++ b/pkg/converters/imported_transactions.go @@ -2,21 +2,21 @@ package converters import "github.com/mayswind/ezbookkeeping/pkg/models" -// ImportTransactionSlice represents the slice data structure of import transaction data -type ImportTransactionSlice []*models.Transaction +// ImportedTransactionSlice represents the slice data structure of import transaction data +type ImportedTransactionSlice []*models.Transaction // Len returns the count of items -func (s ImportTransactionSlice) Len() int { +func (s ImportedTransactionSlice) Len() int { return len(s) } // Swap swaps two items -func (s ImportTransactionSlice) Swap(i, j int) { +func (s ImportedTransactionSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } // Less reports whether the first item is less than the second one -func (s ImportTransactionSlice) Less(i, j int) bool { +func (s ImportedTransactionSlice) Less(i, j int) bool { if s[i].Type != s[j].Type && (s[i].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE || s[j].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE) { if s[i].Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { return true diff --git a/pkg/converters/import_transactions_test.go b/pkg/converters/imported_transactions_test.go similarity index 97% rename from pkg/converters/import_transactions_test.go rename to pkg/converters/imported_transactions_test.go index 84660ecc..fd17956f 100644 --- a/pkg/converters/import_transactions_test.go +++ b/pkg/converters/imported_transactions_test.go @@ -10,7 +10,7 @@ import ( ) func TestImportTransactionSliceLess(t *testing.T) { - var transactionSlice ImportTransactionSlice + var transactionSlice ImportedTransactionSlice transactionSlice = append(transactionSlice, &models.Transaction{ TransactionId: 1, Type: models.TRANSACTION_DB_TYPE_EXPENSE, diff --git a/pkg/converters/data_converter.go b/pkg/converters/transaction_data_converter.go similarity index 64% rename from pkg/converters/data_converter.go rename to pkg/converters/transaction_data_converter.go index 7b2107b9..f3b86038 100644 --- a/pkg/converters/data_converter.go +++ b/pkg/converters/transaction_data_converter.go @@ -4,11 +4,20 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/models" ) -// DataConverter defines the structure of data converter -type DataConverter interface { +// TransactionDataExporter defines the structure of transaction data exporter +type TransactionDataExporter interface { // ToExportedContent returns the exported data ToExportedContent(uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) ([]byte, error) +} +// TransactionDataImporter defines the structure of transaction data importer +type TransactionDataImporter interface { // ParseImportedData returns the imported data ParseImportedData(user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) ([]*models.Transaction, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) } + +// TransactionDataConverter defines the structure of transaction data converter +type TransactionDataConverter interface { + TransactionDataExporter + TransactionDataImporter +}