diff --git a/go.mod b/go.mod index 2a12ae27..cd87488a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/minio/minio-go/v7 v7.0.74 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.4.0 + github.com/shakinm/xlsReader v0.9.12 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v2 v2.27.3 @@ -58,6 +59,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/memcachier/mc/v3 v3.0.3 // indirect + github.com/metakeule/fmtdate v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 3028dd3c..7bb8296b 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4= github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug= +github.com/metakeule/fmtdate v1.1.2 h1:n9M7H9HfAqp+6OA98wXGMdcAr6omshSNVct65Bks1lQ= +github.com/metakeule/fmtdate v1.1.2/go.mod h1:2JyMFlKxeoGy1qS6obQukT0AL0Y4iNANQL8scbSdT4E= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.74 h1:fTo/XlPBTSpo3BAMshlwKL5RspXRv9us5UeHEGYCFe0= @@ -121,6 +123,8 @@ github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shakinm/xlsReader v0.9.12 h1:F6GWYtCzfzQqdIuqZJ0MU3YJ7uwH1ofJtmTKyWmANQk= +github.com/shakinm/xlsReader v0.9.12/go.mod h1:ME9pqIGf+547L4aE4YTZzwmhsij+5K9dR+k84OO6WSs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -165,8 +169,10 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= diff --git a/pkg/converters/feidee_mymoney_transaction_data_excel_file_data_table.go b/pkg/converters/feidee_mymoney_transaction_data_excel_file_data_table.go new file mode 100644 index 00000000..2a2d0e71 --- /dev/null +++ b/pkg/converters/feidee_mymoney_transaction_data_excel_file_data_table.go @@ -0,0 +1,229 @@ +package converters + +import ( + "bytes" + "time" + + "github.com/shakinm/xlsReader/xls" + + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// feideeMymoneyTransactionExcelFileDataTable defines the structure of feidee mymoney transaction plain text data table +type feideeMymoneyTransactionExcelFileDataTable struct { + workbook *xls.Workbook + headerLineColumnNames []string +} + +// feideeMymoneyTransactionExcelFileDataRow defines the structure of feidee mymoney transaction plain text data row +type feideeMymoneyTransactionExcelFileDataRow struct { + sheet *xls.Sheet + rowIndex int +} + +// feideeMymoneyTransactionExcelFileDataRowIterator defines the structure of feidee mymoney transaction plain text data row iterator +type feideeMymoneyTransactionExcelFileDataRowIterator struct { + dataTable *feideeMymoneyTransactionExcelFileDataTable + currentTableIndex int + currentRowIndexInTable int +} + +// DataRowCount returns the total count of data row +func (t *feideeMymoneyTransactionExcelFileDataTable) DataRowCount() int { + allSheets := t.workbook.GetSheets() + totalDataRowCount := 0 + + for i := 0; i < len(allSheets); i++ { + sheet := allSheets[i] + + if sheet.GetNumberRows() <= 1 { + continue + } + + totalDataRowCount += sheet.GetNumberRows() - 1 + } + + return totalDataRowCount +} + +// HeaderLineColumnNames returns the header column name list +func (t *feideeMymoneyTransactionExcelFileDataTable) HeaderLineColumnNames() []string { + return t.headerLineColumnNames +} + +// DataRowIterator returns the iterator of data row +func (t *feideeMymoneyTransactionExcelFileDataTable) DataRowIterator() ImportedDataRowIterator { + return &feideeMymoneyTransactionExcelFileDataRowIterator{ + dataTable: t, + currentTableIndex: 0, + currentRowIndexInTable: 0, + } +} + +// ColumnCount returns the total count of column in this data row +func (r *feideeMymoneyTransactionExcelFileDataRow) ColumnCount() int { + row, err := r.sheet.GetRow(r.rowIndex) + + if err != nil { + return 0 + } + + return len(row.GetCols()) +} + +// GetData returns the data in the specified column index +func (r *feideeMymoneyTransactionExcelFileDataRow) GetData(columnIndex int) string { + row, err := r.sheet.GetRow(r.rowIndex) + + if err != nil { + return "" + } + + cell, err := row.GetCol(columnIndex) + + if err != nil { + return "" + } + + return cell.GetString() +} + +// GetTime returns the time in the specified column index +func (r *feideeMymoneyTransactionExcelFileDataRow) GetTime(columnIndex int, timezoneOffset int16) (time.Time, error) { + str := r.GetData(columnIndex) + + if utils.IsValidLongDateTimeFormat(str) { + return utils.ParseFromLongDateTime(str, timezoneOffset) + } + + if utils.IsValidLongDateTimeWithoutSecondFormat(str) { + return utils.ParseFromLongDateTimeWithoutSecond(str, timezoneOffset) + } + + if utils.IsValidLongDateFormat(str) { + return utils.ParseFromLongDateTimeWithoutSecond(str+" 00:00", timezoneOffset) + } + + return time.Unix(0, 0), errs.ErrTransactionTimeInvalid +} + +// GetTimezoneOffset returns the time zone offset in the specified column index +func (r *feideeMymoneyTransactionExcelFileDataRow) GetTimezoneOffset(columnIndex int) (*time.Location, error) { + return nil, errs.ErrNotSupported +} + +// HasNext returns whether the iterator does not reach the end +func (t *feideeMymoneyTransactionExcelFileDataRowIterator) HasNext() bool { + allSheets := t.dataTable.workbook.GetSheets() + + if t.currentTableIndex >= len(allSheets) { + return false + } + + currentSheet := allSheets[t.currentTableIndex] + + if t.currentRowIndexInTable+1 < currentSheet.GetNumberRows() { + return true + } + + for i := t.currentTableIndex + 1; i < len(allSheets); i++ { + sheet := allSheets[i] + + if sheet.GetNumberRows() <= 1 { + continue + } + + return true + } + + return false +} + +// Next returns the next imported data row +func (t *feideeMymoneyTransactionExcelFileDataRowIterator) Next() ImportedDataRow { + allSheets := t.dataTable.workbook.GetSheets() + currentRowIndexInTable := t.currentRowIndexInTable + + for i := t.currentTableIndex; i < len(allSheets); i++ { + sheet := allSheets[i] + + if currentRowIndexInTable+1 < sheet.GetNumberRows() { + t.currentRowIndexInTable++ + currentRowIndexInTable = t.currentRowIndexInTable + break + } + + t.currentTableIndex++ + t.currentRowIndexInTable = 0 + currentRowIndexInTable = 0 + } + + if t.currentTableIndex >= len(allSheets) { + return nil + } + + currentSheet := allSheets[t.currentTableIndex] + + if t.currentRowIndexInTable >= currentSheet.GetNumberRows() { + return nil + } + + return &feideeMymoneyTransactionExcelFileDataRow{ + sheet: ¤tSheet, + rowIndex: t.currentRowIndexInTable, + } +} + +func createNewFeideeMymoneyTransactionExcelFileDataTable(data []byte) (*feideeMymoneyTransactionExcelFileDataTable, error) { + reader := bytes.NewReader(data) + workbook, err := xls.OpenReader(reader) + + if err != nil { + return nil, err + } + + allSheets := workbook.GetSheets() + var headerRowItems []string + + for i := 0; i < len(allSheets); i++ { + sheet := allSheets[i] + + if sheet.GetNumberRows() < 1 { + continue + } + + row, err := sheet.GetRow(0) + + if err != nil { + return nil, err + } + + cells := row.GetCols() + + if i == 0 { + for j := 0; j < len(cells); j++ { + headerItem := cells[j].GetString() + + if headerItem == "" { + break + } + + headerRowItems = append(headerRowItems, headerItem) + } + } else { + for j := 0; j < min(len(cells), len(headerRowItems)); j++ { + headerItem := cells[j].GetString() + + if headerItem != headerRowItems[j] { + return nil, errs.ErrFieldsInMultiTableAreDifferent + } + } + } + } + + return &feideeMymoneyTransactionExcelFileDataTable{ + workbook: &workbook, + headerLineColumnNames: headerRowItems, + }, nil +} diff --git a/pkg/converters/feidee_mymoney_transaction_data_xls_file_importer.go b/pkg/converters/feidee_mymoney_transaction_data_xls_file_importer.go new file mode 100644 index 00000000..455be6f2 --- /dev/null +++ b/pkg/converters/feidee_mymoney_transaction_data_xls_file_importer.go @@ -0,0 +1,50 @@ +package converters + +import ( + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" +) + +// feideeMymoneyTransactionDataXlsImporter defines the structure of feidee mymoney xls importer for transaction data +type feideeMymoneyTransactionDataXlsImporter struct { + DataTableTransactionDataImporter +} + +var feideeMymoneyDataColumnNameMapping = map[DataTableColumn]string{ + DATA_TABLE_TRANSACTION_TIME: "日期", + DATA_TABLE_TRANSACTION_TYPE: "交易类型", + DATA_TABLE_CATEGORY: "分类", + DATA_TABLE_SUB_CATEGORY: "子分类", + DATA_TABLE_ACCOUNT_NAME: "账户1", + DATA_TABLE_AMOUNT: "金额", + DATA_TABLE_RELATED_ACCOUNT_NAME: "账户2", + DATA_TABLE_DESCRIPTION: "备注", +} + +var feideeMymoneyTransactionTypeNameMapping = map[models.TransactionType]string{ + models.TRANSACTION_TYPE_MODIFY_BALANCE: "余额变更", + models.TRANSACTION_TYPE_INCOME: "收入", + models.TRANSACTION_TYPE_EXPENSE: "支出", + models.TRANSACTION_TYPE_TRANSFER: "转账", +} + +// Initialize an feidee mymoney transaction data xls file importer singleton instance +var ( + FeideeMymoneyTransactionDataXlsImporter = &feideeMymoneyTransactionDataXlsImporter{ + DataTableTransactionDataImporter{ + dataColumnMapping: feideeMymoneyDataColumnNameMapping, + transactionTypeMapping: feideeMymoneyTransactionTypeNameMapping, + }, + } +) + +// ParseImportedData returns the imported data by parsing the feidee mymoney transaction xls data +func (c *feideeMymoneyTransactionDataXlsImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, categoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionTag, error) { + dataTable, err := createNewFeideeMymoneyTransactionExcelFileDataTable(data) + + if err != nil { + return nil, nil, nil, nil, err + } + + return c.parseImportedData(ctx, user, dataTable, defaultTimezoneOffset, accountMap, categoryMap, tagMap) +} diff --git a/pkg/converters/feidee_mymoney_transaction_data_xls_file_importer_test.go b/pkg/converters/feidee_mymoney_transaction_data_xls_file_importer_test.go new file mode 100644 index 00000000..ffb1f3f8 --- /dev/null +++ b/pkg/converters/feidee_mymoney_transaction_data_xls_file_importer_test.go @@ -0,0 +1,102 @@ +package converters + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +func TestFeideeMymoneyTransactionDataXlsImporterParseImportedData_MinimumValidData(t *testing.T) { + converter := FeideeMymoneyTransactionDataXlsImporter + context := core.NewNullContext() + + user := &models.User{ + Uid: 1234567890, + DefaultCurrency: "CNY", + } + + testdata, err := os.ReadFile("../../testdata/feidee_mymoney_test_file.xls") + assert.Nil(t, err) + + allNewTransactions, allNewAccounts, allNewSubCategories, allNewTags, err := converter.ParseImportedData(context, user, testdata, 0, nil, nil, nil) + assert.Nil(t, err) + + assert.Equal(t, 6, len(allNewTransactions)) + assert.Equal(t, 2, len(allNewAccounts)) + assert.Equal(t, 5, len(allNewSubCategories)) + assert.Equal(t, 0, len(allNewTags)) + + assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type) + assert.Equal(t, "2024-09-01 00:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC)) + assert.Equal(t, int64(12345), allNewTransactions[0].Amount) + assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName) + assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[1].Type) + assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC)) + assert.Equal(t, int64(12), allNewTransactions[1].Amount) + assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName) + assert.Equal(t, "Test Category", allNewTransactions[1].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type) + assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC)) + assert.Equal(t, int64(100), allNewTransactions[2].Amount) + assert.Equal(t, "Test Account2", allNewTransactions[2].OriginalSourceAccountName) + assert.Equal(t, "Test Category2", allNewTransactions[2].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type) + assert.Equal(t, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC)) + assert.Equal(t, int64(5), allNewTransactions[3].Amount) + assert.Equal(t, "Test Comment5", allNewTransactions[3].Comment) + assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName) + assert.Equal(t, "Test Account2", allNewTransactions[3].OriginalDestinationAccountName) + assert.Equal(t, "Test Category3", allNewTransactions[3].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[4].Type) + assert.Equal(t, "2024-09-10 00:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime), time.UTC)) + assert.Equal(t, int64(-54300), allNewTransactions[4].Amount) + assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalSourceAccountName) + assert.Equal(t, "Test Category5", allNewTransactions[4].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid) + assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[5].Type) + assert.Equal(t, "2024-09-11 05:06:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime), time.UTC)) + assert.Equal(t, int64(-12340), allNewTransactions[5].Amount) + assert.Equal(t, "Line1\nLine2", allNewTransactions[5].Comment) + assert.Equal(t, "Test Account", allNewTransactions[5].OriginalSourceAccountName) + assert.Equal(t, "Test Category4", allNewTransactions[5].OriginalCategoryName) + + assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) + assert.Equal(t, "Test Account", allNewAccounts[0].Name) + assert.Equal(t, "CNY", allNewAccounts[0].Currency) + + assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid) + assert.Equal(t, "Test Account2", allNewAccounts[1].Name) + assert.Equal(t, "CNY", allNewAccounts[1].Currency) + + assert.Equal(t, int64(1234567890), allNewSubCategories[0].Uid) + assert.Equal(t, "Test Category", allNewSubCategories[0].Name) + + assert.Equal(t, int64(1234567890), allNewSubCategories[1].Uid) + assert.Equal(t, "Test Category5", allNewSubCategories[1].Name) + + assert.Equal(t, int64(1234567890), allNewSubCategories[2].Uid) + assert.Equal(t, "Test Category2", allNewSubCategories[2].Name) + + assert.Equal(t, int64(1234567890), allNewSubCategories[3].Uid) + assert.Equal(t, "Test Category4", allNewSubCategories[3].Name) + + assert.Equal(t, int64(1234567890), allNewSubCategories[4].Uid) + assert.Equal(t, "Test Category3", allNewSubCategories[4].Name) +} diff --git a/pkg/converters/transaction_data_converters.go b/pkg/converters/transaction_data_converters.go index ca2918e6..6d2bd962 100644 --- a/pkg/converters/transaction_data_converters.go +++ b/pkg/converters/transaction_data_converters.go @@ -19,6 +19,8 @@ func GetTransactionDataImporter(fileType string) (TransactionDataImporter, error return EzBookKeepingTransactionDataCSVFileConverter, nil } else if fileType == "ezbookkeeping_tsv" { return EzBookKeepingTransactionDataTSVFileConverter, nil + } else if fileType == "feidee_mymoney_xls" { + return FeideeMymoneyTransactionDataXlsImporter, nil } else { return nil, errs.ErrImportFileTypeNotSupported } diff --git a/pkg/errs/converter.go b/pkg/errs/converter.go index 2ee94748..72518301 100644 --- a/pkg/errs/converter.go +++ b/pkg/errs/converter.go @@ -15,4 +15,5 @@ var ( ErrDestinationAccountNameCannotBeBlank = NewNormalError(NormalSubcategoryConverter, 8, http.StatusBadRequest, "destination account name cannot be blank") ErrAmountInvalid = NewNormalError(NormalSubcategoryConverter, 9, http.StatusBadRequest, "transaction amount is invalid") ErrGeographicLocationInvalid = NewNormalError(NormalSubcategoryConverter, 10, http.StatusBadRequest, "geographic location is invalid") + ErrFieldsInMultiTableAreDifferent = NewNormalError(NormalSubcategoryConverter, 11, http.StatusBadRequest, "fields in multiple table headers are different") ) diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index 9ea5897e..1ec7bf60 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -136,7 +136,8 @@ func ParseFromLongDateTime(t string, utcOffset int16) (time.Time, error) { } // ParseFromLongDateTimeWithoutSecond parses a formatted string in long date time format (no second) -func ParseFromLongDateTimeWithoutSecond(t string, timezone *time.Location) (time.Time, error) { +func ParseFromLongDateTimeWithoutSecond(t string, utcOffset int16) (time.Time, error) { + timezone := time.FixedZone("Timezone", int(utcOffset)*60) return time.ParseInLocation(longDateTimeWithoutSecondFormat, t, timezone) } diff --git a/pkg/utils/datetimes_test.go b/pkg/utils/datetimes_test.go index 21882fe1..b503d133 100644 --- a/pkg/utils/datetimes_test.go +++ b/pkg/utils/datetimes_test.go @@ -133,7 +133,7 @@ func TestParseFromLongDateTime(t *testing.T) { func TestParseFromLongDateTimeWithoutSecond(t *testing.T) { expectedValue := int64(1691947440) - actualTime, err := ParseFromLongDateTimeWithoutSecond("2023-08-13 17:24", time.UTC) + actualTime, err := ParseFromLongDateTimeWithoutSecond("2023-08-13 17:24", 0) assert.Equal(t, nil, err) actualValue := actualTime.Unix() diff --git a/pkg/utils/regexps.go b/pkg/utils/regexps.go index 19a54f19..6f2ad057 100644 --- a/pkg/utils/regexps.go +++ b/pkg/utils/regexps.go @@ -3,9 +3,12 @@ package utils import "regexp" var ( - usernamePattern = regexp.MustCompile("^(?i)[a-z0-9_-]+$") - emailPattern = regexp.MustCompile("^(?i)(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])$") - hexRGBColorPattern = regexp.MustCompile("^(?i)([0-9a-f]{6}|[0-9a-f]{3})$") + usernamePattern = regexp.MustCompile("^(?i)[a-z0-9_-]+$") + emailPattern = regexp.MustCompile("^(?i)(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])$") + hexRGBColorPattern = regexp.MustCompile("^(?i)([0-9a-f]{6}|[0-9a-f]{3})$") + longDateTimePattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01]) ([0-1][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$") + longDateTimeWithoutSecondPattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01]) ([0-1][0-9]|2[0-3]):([0-5][0-9])$") + longDatePattern = regexp.MustCompile("^([1-9][0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[01])$") ) // IsValidUsername reports whether username is valid @@ -22,3 +25,18 @@ func IsValidEmail(email string) bool { func IsValidHexRGBColor(color string) bool { return hexRGBColorPattern.MatchString(color) } + +// IsValidLongDateTimeFormat reports whether long date time is valid format +func IsValidLongDateTimeFormat(datetime string) bool { + return longDateTimePattern.MatchString(datetime) +} + +// IsValidLongDateTimeWithoutSecondFormat reports long date time without seconds is valid format +func IsValidLongDateTimeWithoutSecondFormat(datetime string) bool { + return longDateTimeWithoutSecondPattern.MatchString(datetime) +} + +// IsValidLongDateFormat reports long date is valid format +func IsValidLongDateFormat(date string) bool { + return longDatePattern.MatchString(date) +} diff --git a/pkg/utils/regexps_test.go b/pkg/utils/regexps_test.go index b7b3f03c..3da8d5af 100644 --- a/pkg/utils/regexps_test.go +++ b/pkg/utils/regexps_test.go @@ -112,3 +112,120 @@ func TestIsValidHexRGBColor_InvalidHexRGBColor(t *testing.T) { actualValue = IsValidHexRGBColor(color) assert.Equal(t, expectedValue, actualValue) } + +func TestIsValidLongDateTimeFormat_ValidLongDateTimeFormat(t *testing.T) { + datetime := "2024-09-01 12:34:56" + expectedValue := true + actualValue := IsValidLongDateTimeFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-10-01 00:00:00" + expectedValue = true + actualValue = IsValidLongDateTimeFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "9999-12-31 23:59:59" + expectedValue = true + actualValue = IsValidLongDateTimeFormat(datetime) + assert.Equal(t, expectedValue, actualValue) +} + +func TestIsValidLongDateTimeFormat_InvalidLongDateTimeFormat(t *testing.T) { + datetime := "2024-09-01" + expectedValue := false + actualValue := IsValidLongDateTimeFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12" + expectedValue = false + actualValue = IsValidLongDateTimeFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12:34" + expectedValue = false + actualValue = IsValidLongDateTimeFormat(datetime) + assert.Equal(t, expectedValue, actualValue) +} + +func TestIsValidLongDateTimeWithoutSecondFormat_ValidLongDateTimeWithoutSecondFormat(t *testing.T) { + datetime := "2024-09-01 12:34" + expectedValue := true + actualValue := IsValidLongDateTimeWithoutSecondFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-10-01 00:00" + expectedValue = true + actualValue = IsValidLongDateTimeWithoutSecondFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "9999-12-31 23:59" + expectedValue = true + actualValue = IsValidLongDateTimeWithoutSecondFormat(datetime) + assert.Equal(t, expectedValue, actualValue) +} + +func TestIsValidLongDateTimeWithoutSecondFormat_InvalidLongDateTimeWithoutSecondFormat(t *testing.T) { + datetime := "2024-09-01" + expectedValue := false + actualValue := IsValidLongDateTimeWithoutSecondFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12" + expectedValue = false + actualValue = IsValidLongDateTimeWithoutSecondFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12:34:56" + expectedValue = false + actualValue = IsValidLongDateTimeWithoutSecondFormat(datetime) + assert.Equal(t, expectedValue, actualValue) +} + +func TestIsValidLongDateFormat_ValidLongDateFormat(t *testing.T) { + datetime := "2024-09-01" + expectedValue := true + actualValue := IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "9999-12-31" + expectedValue = true + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) +} + +func TestIsValidLongDateFormat_InvalidLongDateFormat(t *testing.T) { + datetime := "24-09-01" + expectedValue := false + actualValue := IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-9-1" + expectedValue = false + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-1" + expectedValue = false + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-9-01" + expectedValue = false + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12" + expectedValue = false + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12:34" + expectedValue = false + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) + + datetime = "2024-09-01 12:34:56" + expectedValue = false + actualValue = IsValidLongDateFormat(datetime) + assert.Equal(t, expectedValue, actualValue) +} diff --git a/src/consts/file.js b/src/consts/file.js index 254d0565..ef1511d3 100644 --- a/src/consts/file.js +++ b/src/consts/file.js @@ -10,6 +10,11 @@ const supportedImportFileTypes = [ type: 'ezbookkeeping_tsv', name: 'ezbookkeeping Data Export File (TSV)', extensions: '.tsv' + }, + { + type: 'feidee_mymoney_xls', + name: 'Feidee MyMoney (Web) Data Export File', + extensions: '.xls' } ]; diff --git a/src/locales/en.json b/src/locales/en.json index fa305840..65fce611 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1117,6 +1117,7 @@ "destination account name cannot be blank": "Destination account name cannot be blank", "transaction amount is invalid": "Transaction amount is invalid", "geographic location is invalid": "Geographic location is invalid", + "fields in multiple table headers are different": "Fields in multiple table headers are different", "query items cannot be blank": "There are no query items", "query items too much": "There are too many query items", "query items have invalid item": "There is invalid item in query items", @@ -1495,6 +1496,7 @@ "File Type": "File Type", "ezbookkeeping Data Export File (CSV)": "ezbookkeeping Data Export File (CSV)", "ezbookkeeping Data Export File (TSV)": "ezbookkeeping Data Export File (TSV)", + "Feidee MyMoney (Web) Data Export File": "Feidee MyMoney (Web) Data Export File", "Data File": "Data File", "Click to select import file": "Click to select import file", "No data to import": "No data to import", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index e76669cb..983adb66 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1117,6 +1117,7 @@ "destination account name cannot be blank": "目标账户名不能为空", "transaction amount is invalid": "交易金额无效", "geographic location is invalid": "地理位置无效", + "fields in multiple table headers are different": "多个表头中的字段不同", "query items cannot be blank": "请求项目不能为空", "query items too much": "请求项目过多", "query items have invalid item": "请求项目中有非法项目", @@ -1495,6 +1496,7 @@ "File Type": "文件类型", "ezbookkeeping Data Export File (CSV)": "ezbookkeeping 数据导出文件 (CSV)", "ezbookkeeping Data Export File (TSV)": "ezbookkeeping 数据导出文件 (TSV)", + "Feidee MyMoney (Web) Data Export File": "金蝶随手记 (Web版) 数据导出文件", "Data File": "数据文件", "Click to select import file": "点击选择导入文件", "No data to import": "没有可以导入的数据", diff --git a/testdata/feidee_mymoney_test_file.xls b/testdata/feidee_mymoney_test_file.xls new file mode 100644 index 00000000..b9b84756 Binary files /dev/null and b/testdata/feidee_mymoney_test_file.xls differ diff --git a/third-party-dependencies.json b/third-party-dependencies.json index c54a3c3b..f4d05616 100644 --- a/third-party-dependencies.json +++ b/third-party-dependencies.json @@ -111,6 +111,11 @@ "url": "https://github.com/boombuler/barcode", "licenseUrl": "https://github.com/boombuler/barcode/blob/v1.0.2/LICENSE" }, + { + "name": "xlsReader", + "url": "https://github.com/shakinm/xlsReader", + "licenseUrl": "https://github.com/shakinm/xlsReader/blob/v0.9.12/LICENSE" + }, { "name": "go-ordered-map", "url": "https://github.com/wk8/go-ordered-map",