Compare commits

..

1493 Commits

Author SHA1 Message Date
MaysWind 6222b6edae use custom number input box to replace the system input box 2025-06-02 02:05:18 +08:00
MaysWind baa6850fcb code refactor 2025-06-02 01:28:40 +08:00
MaysWind cab13cee3c code refactor 2025-06-01 23:42:35 +08:00
MaysWind b4bff49104 try to keep the selected day when navigating the transaction calendar by month 2025-06-01 01:10:27 +08:00
MaysWind cde26b76b1 don't show exchange rates data provider when use user custom exchange rates data 2025-05-28 22:22:03 +08:00
MaysWind a20ef34280 fix the wrong symbol of Eritrean nakfa 2025-05-28 22:17:58 +08:00
MaysWind 5606d40451 update README.md 2025-05-28 22:15:59 +08:00
MaysWind b3a666f876 support always showing transaction pictures in transaction edit page for mobile version 2025-05-28 00:18:55 +08:00
MaysWind 626d3895aa allow users to set coordinate display type (#141) 2025-05-27 01:01:55 +08:00
MaysWind e338c7190d add missing translation text item 2025-05-26 23:23:36 +08:00
MaysWind adfd12ef52 fix the longitude exceeds 180 degrees when selecting a new geographic location by user 2025-05-26 01:01:07 +08:00
MaysWind 817291c9a7 support user custom exchange rates data 2025-05-26 00:47:19 +08:00
MaysWind c4d20c539f code refactor 2025-05-24 23:27:09 +08:00
MaysWind d089eee133 ignore inline comment in configuration file (#140) 2025-05-24 22:27:49 +08:00
MaysWind 387df07659 fix transaction list not display after expand the month transaction list 2025-05-24 22:25:23 +08:00
MaysWind 5767acb29b improve performance for mobile transaction list page 2025-05-22 03:38:30 +08:00
MaysWind 607c1ddc48 show amount in default currency in transaction edit page / dialog when account currency is not default currency 2025-05-11 23:50:18 +08:00
MaysWind a6d45f5009 show error message when cannot load transaction picture in desktop version 2025-05-11 21:11:22 +08:00
MaysWind 6e9f427182 modify text 2025-05-11 20:36:47 +08:00
MaysWind 62ad1749e8 code refactor 2025-05-11 15:16:49 +08:00
MaysWind 9c695ee46d code refactor 2025-05-10 01:08:57 +08:00
MaysWind 1d2002e92f only months can be selected in transaction calendar mode 2025-05-10 01:05:11 +08:00
MaysWind c7b809415c modify style 2025-05-10 00:08:38 +08:00
MaysWind 55fa9ca686 the size of icon in the title bar follows the font size setting 2025-05-09 23:54:49 +08:00
MaysWind 09a6ea46b2 modify font size 2025-05-09 23:49:19 +08:00
MaysWind 56ba4d88f4 add transaction calendar for mobile version 2025-05-09 01:14:36 +08:00
MaysWind fbc8c5e8c7 modify style 2025-05-08 22:44:24 +08:00
MaysWind 8a65282820 set the calendar readonly when loading 2025-05-08 21:10:10 +08:00
MaysWind 0d8c5f3dbe code refactor 2025-05-07 00:37:37 +08:00
MaysWind dbbbe6805d fix cannot shift month when selected month is current month and in transaction calendar mode 2025-05-07 00:34:11 +08:00
MaysWind 4656002106 modify style 2025-05-07 00:22:32 +08:00
MaysWind d3758ec02f no allow to change month via swiping 2025-05-07 00:11:30 +08:00
MaysWind 81812bb31d add transaction calendar 2025-05-06 00:33:45 +08:00
MaysWind ab6f9839ef amount input supports formula (#130) 2025-05-04 22:56:15 +08:00
MaysWind d036f66d4c fix some number value not display localized decimal symbol 2025-05-04 21:30:04 +08:00
MaysWind dc24186ccb add document link for importing dsv file/data 2025-05-04 15:38:30 +08:00
MaysWind f6fbcd8608 code refactor 2025-05-02 00:34:48 +08:00
MaysWind 381d063295 support clicking on map to set specified geographic location 2025-05-02 00:32:22 +08:00
MaysWind 65a0e48988 fix repeated request error when submitting import transaction again after the first submission failed 2025-05-01 22:09:13 +08:00
MaysWind b1a928b990 update unit test 2025-05-01 13:54:10 +08:00
MaysWind b7973772b3 show process when importing a lot of transactions 2025-05-01 13:49:17 +08:00
MaysWind 20b65fd885 support event stream 2025-04-30 22:30:01 +08:00
MaysWind 850fbffdde improve performance 2025-04-30 00:07:27 +08:00
MaysWind 0af5b194fc add logs 2025-04-30 00:00:32 +08:00
MaysWind c421038808 modify log 2025-04-29 23:17:45 +08:00
MaysWind 68c078038a not map to parent accounts, hidden accounts, and hidden categories when importing transactions 2025-04-28 00:06:59 +08:00
MaysWind be5b1a52ea upgrade third party dependencies 2025-04-27 23:44:40 +08:00
MaysWind 86c5b882c2 upgrade third party dependencies 2025-04-27 23:22:26 +08:00
MaysWind b7d2653fb5 upgrade golang to 1.24.2, nodejs to 22.15.0 2025-04-27 22:49:15 +08:00
MaysWind 34a752b8d8 show edit button for sub account 2025-04-27 00:08:01 +08:00
MaysWind de217a1bbf update timezone display name 2025-04-27 00:01:14 +08:00
MaysWind 3087296263 update alternative language tag 2025-04-26 23:42:36 +08:00
MaysWind 78ba43480b support adding / deleting sub account after account created (#77) 2025-04-26 23:36:23 +08:00
MaysWind e7e2cc8081 fix cannot add preset transaction categories 2025-04-26 22:56:09 +08:00
MaysWind 0f6b61ce6c improve performance 2025-04-26 22:55:51 +08:00
MaysWind 9182e8f2ef code refactor 2025-04-26 22:12:51 +08:00
MaysWind c7870a79e5 not set duplicate submission remark when not enabled 2025-04-26 22:05:18 +08:00
MaysWind 312172fcd8 import outstanding balance modification transaction of feidee mymoney export data (#126) 2025-04-26 00:53:47 +08:00
MaysWind 4a83ba84d3 update default localized setting 2025-04-26 00:05:18 +08:00
Aron 76a9a20d89 add italian translations 2025-04-24 10:06:42 +08:00
MaysWind 567902a407 add language tag alias 2025-04-20 23:44:02 +08:00
MaysWind fca8211c4f fix the incorrect title of categories 2025-04-20 17:45:33 +08:00
MaysWind d37b023e11 modify text 2025-04-20 16:02:42 +08:00
MaysWind f175644843 add Chinese (Traditional) language 2025-04-20 15:55:23 +08:00
MaysWind 13c4ad10c5 update latest time zones 2025-04-20 14:39:49 +08:00
MaysWind 550cd848b0 update exchange rate data reference url for National Bank of Ukraine 2025-04-20 11:05:05 +08:00
MaysWind 25e0c43c0b sort the source code by country name 2025-04-20 10:33:22 +08:00
MaysWind fd9d23995d Merge branch 'main' of https://github.com/mayswind/EasyBookkeeping 2025-04-20 10:29:26 +08:00
Mykyta Lytvynenko 3a467d758e add National Bank of Ukraine exchange rates data source 2025-04-20 10:29:13 +08:00
MaysWind 60b6adfa1e update exchange rate data reference url for National Bank of Romania 2025-04-20 10:22:05 +08:00
Mykyta Lytvynenko a44ac333ab add ukrainian translation 2025-04-19 01:53:54 +08:00
Mykyta Lytvynenko deea0deb3f add ukrainian translation 2025-04-19 01:53:54 +08:00
Mykyta Lytvynenko 97178227ef add ukrainian translation 2025-04-19 01:53:54 +08:00
MaysWind fd1242490f code refactor 2025-04-19 00:04:04 +08:00
MaysWind 1ac633bdd7 use the sub-category according to the primary category name if there are duplicated sub-category names when importing transactions (#119) 2025-04-18 23:33:07 +08:00
MaysWind 44d4349f12 show currency in transaction edit dialog 2025-04-13 23:45:39 +08:00
MaysWind df31be61e8 support hiding/unhiding/deleting sub-account in account list page 2025-04-13 23:36:37 +08:00
MaysWind 68b08c1e8a use primary color & icon as default when creating secondary category 2025-04-06 22:38:49 +08:00
MaysWind f97cca6dcc update the latest supported currencies in exchange rates data from National Bank of Poland 2025-04-04 00:10:14 +08:00
MaysWind 2c9bb12da9 code refactor 2025-04-03 23:50:14 +08:00
MaysWind b059055a93 code refactor 2025-04-03 23:44:06 +08:00
MaysWind 1092cc2fdc update README.md 2025-04-03 22:10:23 +08:00
MaysWind d5e75b2a37 update README.md 2025-03-31 00:09:25 +08:00
MaysWind 0a5f8862ad allow pressing ESC or clicking outside to close add dialog when nothing is modified 2025-03-31 00:00:16 +08:00
MaysWind 433a225b9d modify error message 2025-03-30 00:38:33 +08:00
MaysWind 6dfff84ab7 not allow to close dialog by clicking outside when user has modified something in dialog 2025-03-29 21:14:45 +08:00
MaysWind 91b6047f2e batch create nonexistent transaction tags when import transaction 2025-03-29 21:02:56 +08:00
MaysWind 94ef7f450b update README.md 2025-03-29 16:04:29 +08:00
MaysWind e5cf92f84e batch create nonexistent transaction categories when import transaction 2025-03-24 00:10:50 +08:00
MaysWind 399b5c03a2 code refactor 2025-03-24 00:10:26 +08:00
MaysWind f9b7be2f74 add refresh button in batch replace dialog 2025-03-24 00:09:40 +08:00
MaysWind 66f7cc6f88 set amount format in import dialog 2025-03-24 00:09:12 +08:00
MaysWind af03597e86 move file 2025-03-23 15:09:22 +08:00
MaysWind ab4fc8faf5 modify method name 2025-03-23 14:59:06 +08:00
MaysWind fc2c5a8e6c modify method name 2025-03-23 14:55:17 +08:00
MaysWind 1d23558dff adjust the display order 2025-03-16 22:43:26 +08:00
MaysWind ce65d0257a import transaction from beancount file 2025-03-16 22:41:28 +08:00
MaysWind 78c5b1704a import transactions from Feidee Mymoney (Elecloud) 2025-03-15 21:00:53 +08:00
MaysWind 00f8b6d950 code refactor 2025-03-15 17:27:08 +08:00
MaysWind e829bdccb5 modify struct name 2025-03-15 12:52:05 +08:00
MaysWind 6a7627e8c6 bump version to 0.9.0 2025-03-11 00:29:22 +08:00
MaysWind 6787d0591e support skip specified tests when build release 2025-03-10 22:02:08 +08:00
MaysWind d78dada5ec fix cannot filter transfer in transaction with transaction tags (#82) 2025-03-09 23:40:27 +08:00
MaysWind 74844b9a99 limit the maximum count of password / token check failures per IP/user per minute (#33) 2025-03-09 23:38:54 +08:00
MaysWind a29ff0d553 show add transaction button in desktop navigation bar (#59) 2025-03-09 20:15:04 +08:00
MaysWind 6632dd64b3 fix time zone name not display when creating a new scheduled transaction 2025-03-09 16:57:11 +08:00
MaysWind a8c912c4c2 modify text 2025-03-09 16:50:27 +08:00
MaysWind 2a02816127 fix the selected item not in the center sometimes 2025-03-09 16:42:40 +08:00
MaysWind 66b950b4aa modify text 2025-03-09 16:29:49 +08:00
MaysWind 639bd9c5cd use the new popup select dialog 2025-03-09 16:23:23 +08:00
MaysWind 2bf8c0b501 update README.md 2025-03-09 02:13:07 +08:00
MaysWind 847d5121aa show localized language name in language selection popup 2025-03-09 02:02:29 +08:00
MaysWind ab6e89594e code refactor 2025-03-09 01:30:57 +08:00
MaysWind 0662427cec show currency code in currency select 2025-03-09 01:19:14 +08:00
MaysWind d3b283f623 code refactor 2025-03-09 00:45:58 +08:00
MaysWind 385d97ba15 code refactor 2025-03-09 00:45:48 +08:00
MaysWind 09574f1c75 code refactor 2025-03-09 00:39:54 +08:00
MaysWind 34247be52c show currency code in currency selection popup 2025-03-09 00:00:36 +08:00
MaysWind 56b1e1f565 not display text after title when afterField not set 2025-03-08 22:43:18 +08:00
MaysWind 7c6a3081ee update README.md 2025-03-07 01:51:30 +08:00
MaysWind beeeb1c059 modify language select style 2025-03-07 01:47:27 +08:00
MaysWind 47f70098df update translation 2025-03-07 00:10:40 +08:00
MaysWind 4e6b708834 update translation 2025-03-06 22:40:08 +08:00
MaysWind a359b07ef3 update README.md 2025-03-06 22:39:43 +08:00
tkymmm bb0524c559 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm d70ea1987a Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 001de8a1e0 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm f045e8ffcd Add japanese 2025-03-06 13:31:47 +08:00
tkymmm f8be1222d6 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 8848fe8b33 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 32524dac56 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm a50ecf4d9c Add Japanese translation 2025-03-06 13:31:47 +08:00
tkymmm 98fda4e5d5 Add japanese .go 2025-03-06 13:31:47 +08:00
tkymmm f0c111f02a Update all_locales.go 2025-03-06 13:31:47 +08:00
MaysWind bcc36e1533 modify option default value 2025-03-05 00:35:01 +08:00
MaysWind 872639fefa support sub path 2025-03-04 23:39:16 +08:00
MaysWind e83b959930 convert transaction type in import transaction dialog 2025-03-04 01:27:51 +08:00
MaysWind 3f8de39683 update README.md 2025-03-04 01:27:39 +08:00
MaysWind 9430f57a0b import transaction from custom delimiter-separated values file 2025-03-04 01:27:08 +08:00
MaysWind 703ceb44e4 modify file name 2025-03-02 15:51:45 +08:00
MaysWind 3954db9b99 fix default value not set (#80) 2025-03-01 21:48:34 +08:00
MaysWind 7abb5972eb modify style 2025-03-01 20:38:13 +08:00
MaysWind 377a4899b7 scheduled transaction supports start time and end time (#36) 2025-02-28 00:14:52 +08:00
MaysWind d769e833e7 increase export timeout 2025-02-27 23:04:19 +08:00
MaysWind 9786f96fe5 trim trailing zero in transaction amount when importing OFX file (#73) 2025-02-27 22:11:19 +08:00
MaysWind bd2a672c12 do not set custom user agent for IMF exchange rate data source 2025-02-21 00:17:15 +08:00
MaysWind 96cb45dd45 trim redundant space 2025-02-21 00:11:29 +08:00
MaysWind cf370c083b add format string for log content 2025-02-20 00:09:12 +08:00
MaysWind 43e1780dc8 update comment 2025-02-19 23:55:25 +08:00
MaysWind 6aac810450 not select all text when user actively selects text 2025-02-19 23:34:38 +08:00
MaysWind f14c283a83 upgrade golang to 1.24.0 2025-02-19 23:19:36 +08:00
MaysWind e78b2cafb1 upgrade node.js to 22.14.0, alpine base image to 3.21.3 2025-02-19 23:19:17 +08:00
MaysWind a9eaf011cd fix the parent account not displayed in the account list in the transaction list page 2025-02-19 23:04:27 +08:00
MaysWind 7fca519fd9 fix the incorrect order in account list when there are more than one accounts with multiple sub-accounts in one category 2025-02-19 22:59:30 +08:00
MaysWind a9a37b0c97 fix the Postgres database transaction cannot continue to execute after failure (#50) 2025-02-17 00:32:53 +08:00
MaysWind 8f55bd0df1 add log 2025-02-17 00:01:24 +08:00
MaysWind 85e88949c4 fix clientSessionId not regenerated when use duplicate in desktop version 2025-02-16 23:16:59 +08:00
MaysWind 80bcebbf66 add log 2025-02-16 01:04:46 +08:00
MaysWind 7cae873830 parse split transactions in IIF file into separate transactions 2025-02-16 01:04:24 +08:00
MaysWind 7a1b27927f support two digits year 2025-02-13 23:30:43 +08:00
MaysWind 306da60752 fix wrong log content 2025-02-13 23:19:52 +08:00
MaysWind 4274b90b1e remove unused code 2025-02-12 23:26:31 +08:00
MaysWind 0b5721671d code refactor 2025-02-12 23:26:03 +08:00
MaysWind 30575d15d0 modify parameter name 2025-02-12 23:21:54 +08:00
MaysWind 0ca2f8b4a7 fix the wrong account balance in transaction edit page due to #a0e3a269a0098d05fa1a17eee4cce393869fc5cc 2025-02-12 01:25:46 +08:00
MaysWind 2e01e5530c after the search bar is focused and the screen height is reduced, let the sheet scroll to top 2025-02-12 00:56:08 +08:00
MaysWind 13cc6a2cf0 fix some content exceeds the screen in landscape mode 2025-02-11 23:35:35 +08:00
MaysWind 35ba5dcc9f disable vue-i18n legacy mode 2025-02-11 22:40:19 +08:00
MaysWind ab58109e5e account edit page displays the debt amount instead of the balance for credit card and debt accounts 2025-02-11 00:45:23 +08:00
MaysWind 18a6d25ed6 code refactor 2025-02-10 23:12:02 +08:00
MaysWind a0e3a269a0 fix the wrong display order of savings accounts and certificate of deposit accounts 2025-02-09 23:41:44 +08:00
MaysWind 1658d0758c support input page number in transaction list for desktop 2025-02-09 22:17:17 +08:00
MaysWind 6f0c59bba4 fix the menu not disappear after clicking the page number 2025-02-09 22:04:44 +08:00
MaysWind 1e98a0df55 press enter / tab to use the current input page number 2025-02-09 21:54:38 +08:00
MaysWind fa77d3e837 code refactor 2025-02-09 21:52:48 +08:00
MaysWind a21bc7aad7 code refactor 2025-02-09 21:27:19 +08:00
MaysWind e665fac956 modify style 2025-02-09 21:13:22 +08:00
MaysWind e60c633e56 duplicate transaction with time / geographic location (#36) 2025-02-09 21:11:55 +08:00
MaysWind a444526743 modify style 2025-02-09 20:14:02 +08:00
MaysWind b65a246fcb upgrade third party dependencies 2025-02-09 19:59:57 +08:00
MaysWind c5e8a50033 fix text item key 2025-02-09 19:59:24 +08:00
MaysWind 6021c24da9 format code 2025-02-09 17:50:09 +08:00
MaysWind 0e4cd10376 remove compatibility code for migration 2025-02-09 17:28:20 +08:00
MaysWind f2c043a299 code refactor 2025-02-09 17:28:20 +08:00
MaysWind 596787b998 migrate transaction list page to composition API and typescript 2025-02-09 17:28:20 +08:00
MaysWind bb3a0c4444 update README.md 2025-02-09 17:28:19 +08:00
chrgm 624b9cb20b Add German translation 2025-02-09 17:27:15 +08:00
MaysWind 5a6c25d616 tree view selection sheet supports filtering content (#38) 2025-02-07 23:25:14 +08:00
MaysWind eb178e7bed set save button disabled when tag name is empty 2025-02-07 23:24:58 +08:00
MaysWind 373d71c124 transaction tag selection sheet supports filtering content (#38) 2025-02-07 23:24:41 +08:00
MaysWind 6618cfeceb modify style 2025-02-07 21:36:56 +08:00
MaysWind 58c1382570 show hidden tag when there are no available tags 2025-02-07 00:08:42 +08:00
MaysWind 721eb122bd code refactor 2025-02-06 23:36:03 +08:00
MaysWind 95205d2f1d code refactor 2025-02-06 23:29:08 +08:00
MaysWind b6efa91879 two column list item selection supports filtering content (#38) 2025-02-06 22:37:02 +08:00
MaysWind 29d7ee09c8 code refactor 2025-02-06 21:07:26 +08:00
MaysWind 68cb5bc523 remove unused code 2025-02-06 20:44:18 +08:00
MaysWind bd96b2398a add readonly modifier 2025-02-06 20:32:52 +08:00
MaysWind 1579882475 code refactor 2025-02-06 00:15:04 +08:00
MaysWind b077b99806 do not save transaction draft when category / account / tag is same as the initial value (#37) 2025-02-05 22:38:41 +08:00
MaysWind 9797e7e58f fix the account list page shows skeleton and account info at the same time when reloading the account list for mobile version 2025-02-05 21:46:08 +08:00
MaysWind 00f62fd608 code refactor 2025-02-05 00:19:01 +08:00
MaysWind 833e767e6c migrate transaction edit page to composition API and typescript 2025-02-05 00:02:40 +08:00
MaysWind 3e7b3297aa fix the date ranges of income and expense trends in home page is wrong (#49) 2025-02-04 21:49:43 +08:00
MaysWind 8e170a69e8 migrate template list page to composition API and typescript 2025-02-04 14:38:09 +08:00
MaysWind 00d6f5d473 code refactor 2025-02-04 14:27:22 +08:00
MaysWind 3c363788d8 migrate mobile home page to composition API and typescript 2025-02-04 13:05:32 +08:00
MaysWind cc920cff9a migrate transaction template store to composition API and typescript 2025-02-04 12:45:25 +08:00
MaysWind 5d78d56f0c not allow to sort data in table when importing transactions 2025-02-04 01:36:08 +08:00
MaysWind b9b47c4428 show red checkbox when data is invalid 2025-02-04 01:32:06 +08:00
MaysWind a5382e9fdd show error when no selected file to import 2025-02-04 01:30:54 +08:00
MaysWind 376f5b2650 migrate importing transaction dialog to composition API and typescript 2025-02-04 01:29:25 +08:00
MaysWind 61c5f75006 code refactor 2025-02-03 23:10:10 +08:00
MaysWind 9a6148fe6e code refactor 2025-02-03 21:27:09 +08:00
MaysWind ff6a558e96 code refactor 2025-02-03 20:38:37 +08:00
MaysWind 3ad45bebb7 migrate transaction tag filter page to composition API and typescript 2025-02-03 20:38:30 +08:00
MaysWind 6b152bd778 code refactor 2025-02-03 16:37:11 +08:00
MaysWind aacde2dfde migrate category filter page to composition API and typescript 2025-02-03 15:21:40 +08:00
MaysWind 6971eccb22 code refactor 2025-02-03 14:48:55 +08:00
MaysWind a5e7c483ef ofx 1.x supports utf-8 encoding (#48) 2025-02-02 17:52:17 +08:00
MaysWind 319f97bf9e use QEMU 8.1.5 2025-01-31 23:00:08 +08:00
MaysWind da31a67c52 upgrade actions 2025-01-31 22:33:50 +08:00
MaysWind af355e5b85 migrate account filter page to composition API and typescript 2025-01-29 22:49:14 +08:00
MaysWind ca9fe264b4 code refactor 2025-01-29 22:31:59 +08:00
MaysWind c07e937702 disable controls when importing transactions 2025-01-28 12:22:28 +08:00
MaysWind 371b88c6cd switch to basic tab after clicking duplicate button 2025-01-28 12:05:00 +08:00
MaysWind 782bc11950 migrate transaction store to composition API and typescript 2025-01-28 11:58:43 +08:00
MaysWind 50c3fee7dc fix cannot create transaction tag in transaction edit dialog 2025-01-28 00:40:42 +08:00
MaysWind 51c4e06e59 code refactor 2025-01-28 00:17:42 +08:00
MaysWind 2a84f44f2c code refactor 2025-01-28 00:17:34 +08:00
MaysWind 4878c7258d check whether event target is input element 2025-01-27 20:36:55 +08:00
MaysWind e2a4e0cb3f migrate account list page to composition API and typescript 2025-01-27 20:36:47 +08:00
MaysWind fd3457af84 code refactor 2025-01-27 15:37:55 +08:00
MaysWind 8c0a9062a2 code refactor 2025-01-27 01:22:49 +08:00
MaysWind 10d301aa3c code refactor 2025-01-27 00:45:53 +08:00
MaysWind d7193847c5 code refactor 2025-01-27 00:45:48 +08:00
MaysWind 8ea079679c code refactor 2025-01-27 00:42:23 +08:00
MaysWind 95c7b498ff code refactor 2025-01-27 00:38:43 +08:00
MaysWind 1156499b05 code refactor 2025-01-27 00:36:34 +08:00
MaysWind e92aaffc94 make currency property required 2025-01-27 00:16:42 +08:00
MaysWind 8dc38912c9 select amount value when click the amount text box and the amount value is zero (#36, #45) 2025-01-27 00:05:52 +08:00
MaysWind d228bf12bb update code order and add missing text item 2025-01-26 23:46:53 +08:00
Artemy Egorov 7dff8a2ed5 fix: a couple of entries 2025-01-26 23:40:35 +08:00
Artemy Egorov 814fe02949 feat: full ru frontend locale 2025-01-26 23:40:35 +08:00
Artemy Egorov da702d6316 feat: update other locales 2025-01-26 23:40:35 +08:00
Artemy Egorov fd909023f9 feat: backend russian locale 2025-01-26 23:40:35 +08:00
MaysWind c94c455b8b fix wrong size of button icon in safari 2025-01-26 00:49:15 +08:00
MaysWind d39b0ee077 upgrade third party dependencies 2025-01-25 23:45:20 +08:00
MaysWind 99a2f40a4e add English hint for language option when current language is not English 2025-01-25 23:22:45 +08:00
MaysWind acaad355ed migrate trends bar chart to composition API and typescript 2025-01-25 23:22:38 +08:00
MaysWind ad4b351a32 migrate batch replace dialog to composition API and typescript 2025-01-25 20:07:19 +08:00
MaysWind 2902fae1df code refactor 2025-01-25 20:02:14 +08:00
MaysWind a0b9ca7fae migrate entry and router file to typescript 2025-01-25 16:13:55 +08:00
MaysWind 05d8f8b9ab code refactor 2025-01-25 15:05:47 +08:00
MaysWind d074a9d54a format code 2025-01-25 15:05:37 +08:00
MaysWind d0274013cf fix typo 2025-01-25 15:03:53 +08:00
MaysWind 1e0169a9b7 remove unused code 2025-01-25 15:02:59 +08:00
MaysWind c619d2ecad code refactor 2025-01-25 14:55:40 +08:00
MaysWind a27a2556aa migrate transaction statistics page to composition API and typescript 2025-01-25 14:52:57 +08:00
MaysWind 8207373a05 migrate statistics store to composition API and typescript 2025-01-24 23:16:57 +08:00
MaysWind 986fab9cbf modify struct name 2025-01-24 22:28:31 +08:00
MaysWind 2025551f3c remove unused code 2025-01-24 22:25:34 +08:00
MaysWind 3d934ab018 remove unused code 2025-01-24 22:25:04 +08:00
MaysWind 6a59ed0984 code refactor 2025-01-24 00:42:43 +08:00
MaysWind 8fce3f2bcc migrate account edit page to composition API and typescript 2025-01-24 00:42:37 +08:00
MaysWind eca0574e41 code refactor 2025-01-23 23:40:34 +08:00
MaysWind fa044f5972 code refactor 2025-01-23 23:01:16 +08:00
MaysWind 6d758f338b code refactor 2025-01-23 22:33:14 +08:00
MaysWind 28322bad5e code refactor 2025-01-23 21:57:53 +08:00
MaysWind a9805b8fff migrate mobile pie chart to composition API and typescript 2025-01-23 21:29:41 +08:00
MaysWind eb16b7fbb8 code refactor 2025-01-23 21:10:00 +08:00
MaysWind 70428b6c96 migrate desktop pie chart to composition API and typescript 2025-01-23 00:06:05 +08:00
MaysWind 85557c2879 fix "Save Display Order" still displays bug when adjusting the order and then restoring the original order in the desktop version 2025-01-22 23:12:58 +08:00
MaysWind 5bf7f77520 migrate transaction category list page to composition API and typescript 2025-01-22 23:08:18 +08:00
MaysWind 3cdc7c947f remove unused code 2025-01-22 22:09:56 +08:00
MaysWind 84fc6b2ffb migrate user 2fa setting page to composition API and typescript 2025-01-22 21:47:00 +08:00
MaysWind e7612f6f0c code refactor 2025-01-21 23:47:28 +08:00
MaysWind 58097331da code refactor 2025-01-21 23:47:22 +08:00
MaysWind e053528abf migrate session list page /user security page to composition API and typescript 2025-01-21 23:38:06 +08:00
MaysWind 568abb6e03 update transaction tag icon style 2025-01-21 00:27:45 +08:00
MaysWind 852c7899b5 modify style 2025-01-20 23:59:47 +08:00
MaysWind f4998da4cd migrate user profile page to composition API and typescript 2025-01-20 23:56:09 +08:00
MaysWind 9d9e6ef9bd migrate amount filter page to composition API and typescript 2025-01-20 22:07:47 +08:00
MaysWind fb367174b6 not show session updates toast after password modified 2025-01-20 00:41:23 +08:00
MaysWind 87566010be code refactor 2025-01-20 00:40:46 +08:00
MaysWind 2f7cdfd786 code refactor 2025-01-20 00:40:25 +08:00
MaysWind 559e8259be migrate root store to composition API and typescript 2025-01-20 00:40:02 +08:00
MaysWind 929d3febb0 remove unused code 2025-01-19 22:20:19 +08:00
MaysWind 92df98c3fb migrate signup page to composition API and typescript 2025-01-19 22:13:06 +08:00
MaysWind 019c993313 code refactor 2025-01-19 21:06:26 +08:00
MaysWind 3c370b7ac7 migrate forget password / reset password / verify email page to composition API and typescript 2025-01-19 20:57:55 +08:00
MaysWind 1afd811aa8 migrate login page to composition API and typescript 2025-01-19 20:54:10 +08:00
MaysWind 0a999d56c7 code refactor 2025-01-19 19:14:23 +08:00
MaysWind 4f6988d775 code refactor 2025-01-19 18:26:00 +08:00
MaysWind 9f2bbe527e migrate desktop home page to composition API and typescript 2025-01-19 00:48:27 +08:00
MaysWind 965be837a3 migrate transaction category edit page to composition API and typescript 2025-01-18 23:47:49 +08:00
MaysWind f5f8b9a145 code refactor 2025-01-18 23:36:02 +08:00
MaysWind f3b6c1266d code refactor 2025-01-18 23:35:43 +08:00
MaysWind c675057ab1 code refactor 2025-01-18 22:50:13 +08:00
MaysWind 20e95e35aa migrate preset dialog to composition API and typescript 2025-01-18 22:33:58 +08:00
MaysWind f22666e756 migrate account store to composition API and typescript 2025-01-18 00:37:44 +08:00
MaysWind 3567ac170a set name column length to 64 2025-01-17 22:02:33 +08:00
MiGueL0n 260bd952d4 Fix error when adding transactions 2025-01-17 20:31:05 +08:00
MiGueL0n e294f04b04 Update es.go
Change variable name from "en" to "es" to fix errors.
2025-01-17 13:04:25 +08:00
MiGueL0n 27fa5625be Backend spanish language
Sorry is my first pull 🤦‍♂️ i forgot to add Spanish in backend structure.
2025-01-17 12:09:22 +08:00
MiGueL0n 62623e6a23 Update es.json
Fix the error of the app name translated into Spanish with its original name.
2025-01-17 12:09:22 +08:00
MiGueL0n 9df436d31e Changes to do the things right 🙃 2025-01-17 12:09:22 +08:00
MiGueL0n c03f74154b Added Spanish locale options and translations 2025-01-17 12:09:22 +08:00
MaysWind 749bdfd164 migrate lib/account.js to typescript 2025-01-17 00:37:41 +08:00
MaysWind 6878d5260d code refactor 2025-01-16 23:16:20 +08:00
MaysWind 4f21762533 migrate app lock settings page to composition API and typescript 2025-01-16 22:37:35 +08:00
MaysWind adebc96637 code refactor 2025-01-16 21:35:56 +08:00
MaysWind 3a50e6d2de fix error not display in desktop 2025-01-16 21:32:20 +08:00
MaysWind b09b66adc3 migrate transaction category preset page to composition API and typescript 2025-01-16 00:43:50 +08:00
MaysWind 6ef42a9303 code refactor 2025-01-16 00:25:50 +08:00
MaysWind 922c338387 code refactor 2025-01-15 23:15:42 +08:00
MaysWind dc4310c301 code refactor 2025-01-15 22:36:38 +08:00
MaysWind 0b7fd647e6 add noImplicitOverride and noPropertyAccessFromIndexSignature compiler options 2025-01-15 22:19:28 +08:00
MaysWind 081b270f04 migrate transaction categories page to composition API and typescript 2025-01-14 23:48:06 +08:00
MaysWind 29c09cb10a migrate unlock page to composition API and typescript 2025-01-14 23:36:31 +08:00
MaysWind cd2e6c1aae migrate two column select to composition API and typescript 2025-01-14 22:23:24 +08:00
MaysWind d3e6756c22 code refactor 2025-01-14 22:20:19 +08:00
MaysWind 7d31812055 code refactor 2025-01-14 21:20:40 +08:00
MaysWind 8ce871e9bb migrate two column list item selection sheet to composition API and typescript 2025-01-14 00:38:15 +08:00
MaysWind 30125f0faa modify style 2025-01-14 00:37:46 +08:00
MaysWind f4ea9a85f0 code refactor 2025-01-14 00:14:07 +08:00
MaysWind 593e123610 remove unused code 2025-01-13 23:55:19 +08:00
MaysWind 4e8eddd868 migrate user data management page to composition API and typescript 2025-01-13 23:19:06 +08:00
MaysWind 041dbcb5c7 code refactor 2025-01-13 23:05:21 +08:00
MaysWind 215a0163b6 fix the incorrect language default time format 2025-01-13 22:30:05 +08:00
MaysWind a38867ce01 not allow to input or paste when amount input is set to readonly or disabled (#36) 2025-01-13 22:03:55 +08:00
MaysWind 9026c526f8 code refactor 2025-01-13 21:59:09 +08:00
MaysWind c1f94a4499 code refactor 2025-01-13 21:40:17 +08:00
MaysWind 2be329974e migrate transaction category store to composition API and typescript 2025-01-12 23:47:56 +08:00
MaysWind abb26ac410 migrate app settings page to composition API and typescript 2025-01-12 20:39:15 +08:00
MaysWind c5f03165bc migrate exchange rates page to composition API and typescript 2025-01-12 19:23:20 +08:00
MaysWind 41452ac20b add exception log 2025-01-12 18:16:20 +08:00
MaysWind a285707b53 migrate tree view selection sheet to composition API and typescript 2025-01-12 17:42:01 +08:00
MaysWind cc9a5eea36 display prefix in one line 2025-01-12 17:20:08 +08:00
MaysWind 89d5cc31af migrate amount input to composition API and typescript 2025-01-12 16:53:20 +08:00
MaysWind f0e11e952c remove unused code 2025-01-12 15:31:43 +08:00
MaysWind 0f8431ff5c remove space decimal separator 2025-01-12 15:29:26 +08:00
MaysWind 659b819011 code refactor 2025-01-12 15:10:22 +08:00
MaysWind 6a62cfdef7 code refactor 2025-01-12 15:10:07 +08:00
MaysWind f2ebd751d4 migrate app and user setting page framework to composition API and typescript 2025-01-12 12:55:45 +08:00
MaysWind 1414f54a12 migrate statistics setting page to composition API and typescript 2025-01-12 12:39:28 +08:00
MaysWind c3265c5bf6 migrate text size setting page to composition API and typescript 2025-01-12 11:45:14 +08:00
MaysWind 13e322bb57 code refactor 2025-01-12 10:58:12 +08:00
MaysWind 5cacfc8daf migrate income&expense overview card and monthly income&expense card to composition API and typescript 2025-01-12 02:10:40 +08:00
MaysWind 9bbe4d2dcf code refactor 2025-01-12 01:04:19 +08:00
MaysWind b517409229 migrate schedule frequency select / selection sheet to composition API and typescript 2025-01-12 01:02:03 +08:00
MaysWind 75a96e871a migrate overview list item selection sheet to composition API and typescript 2025-01-12 00:06:24 +08:00
MaysWind 395f7dfd63 migrate webauthn.js to typescript 2025-01-11 23:49:47 +08:00
MaysWind b166f6ff56 code refactor 2025-01-11 23:10:17 +08:00
MaysWind eb2b6d1002 migrate transaction tag list page to composition API and typescript 2025-01-11 23:09:03 +08:00
MaysWind 6cb045453a migrate transaction tag sheet to composition API and typescript 2025-01-11 20:12:15 +08:00
MaysWind ffae9e81a7 migrate transaction tag store to composition API and typescript 2025-01-11 19:53:09 +08:00
MaysWind dc59d3954a remove unused code 2025-01-11 18:15:10 +08:00
MaysWind 7b26eb50bf fix trend analysis not reload data when set custom month range in mobile version 2025-01-11 18:08:39 +08:00
MaysWind c0ab1ad793 fix the dark mode not take effect in mobile photo browser 2025-01-11 18:06:55 +08:00
MaysWind c73dcb51e4 migrate month range selection sheet / dialog to composition API and typescript 2025-01-11 17:57:14 +08:00
MaysWind 8c7fc0fef9 migrate datetime selection sheet to composition API and typescript 2025-01-11 16:51:56 +08:00
MaysWind 76a2e24d06 migrate datetime select to composition API and typescript 2025-01-11 15:58:09 +08:00
MaysWind 3e6a054913 migrate date range selection sheet / dialog to composition API and typescript 2025-01-11 15:44:54 +08:00
MaysWind 89b233e51b code refactor 2025-01-11 14:05:17 +08:00
MaysWind 61f26e060e code refactor 2025-01-11 02:50:59 +08:00
MaysWind 5649bb243d code refactor 2025-01-11 02:29:44 +08:00
MaysWind ea90e97f92 update unit test 2025-01-11 02:14:49 +08:00
MaysWind 3c624188d1 code refactor 2025-01-11 02:14:35 +08:00
MaysWind 04f373e931 migrate about page to composition API and typescript 2025-01-11 01:56:50 +08:00
MaysWind b2e36a24fd migrate mobile ui utils to typescript 2025-01-11 00:53:22 +08:00
MaysWind 8da3d2aa35 migrate i18n helper.js some code to typescript and migrate vue file to composition API and typescript 2025-01-11 00:49:21 +08:00
MaysWind 25c8b9baf8 migrate overview store to composition API and typescript 2025-01-09 00:22:28 +08:00
MaysWind 1555052e1d migrate index.js to typescript 2025-01-08 22:59:58 +08:00
MaysWind b1fbf91d6e migrate color/icon selection sheet to composition API and typescript 2025-01-08 22:52:13 +08:00
MaysWind 5dfac0c085 package locales/helper.ts to commons.js 2025-01-07 22:07:36 +08:00
MaysWind cb142a65f3 move file 2025-01-07 00:09:28 +08:00
MaysWind b0a9b2366e code refactor 2025-01-06 23:52:54 +08:00
MaysWind ed897d4105 change file name 2025-01-06 23:12:05 +08:00
MaysWind 2b71723ba1 migrate number pad sheet to composition API and typescript 2025-01-06 22:41:55 +08:00
MaysWind 60ba3b7977 format code 2025-01-06 22:03:28 +08:00
MaysWind e0198da52c fix not show selected transaction category / account / tag 2025-01-06 21:58:52 +08:00
MaysWind 6365805715 code refactor 2025-01-06 21:49:49 +08:00
MaysWind 5e7e3696bf update comments 2025-01-06 21:43:50 +08:00
MaysWind 166fae425d code refactor 2025-01-06 21:42:36 +08:00
MaysWind f56bef40d8 code refactor 2025-01-05 23:54:35 +08:00
MaysWind ad1eec7d47 migrate exchange rates store to composition API and typescript 2025-01-05 23:22:59 +08:00
MaysWind 5b241d2547 migrate 2fa auth store to composition API and typescript 2025-01-05 23:01:32 +08:00
MaysWind 92a626fb21 migrate token store to composition API and typescript 2025-01-05 22:56:07 +08:00
MaysWind 5171f23c09 migrate user store to composition API and typescript 2025-01-05 22:54:53 +08:00
MaysWind 6dc0ebcac6 code refactor 2025-01-05 21:39:39 +08:00
MaysWind 49f1f3c86b code refactor 2025-01-05 20:43:19 +08:00
MaysWind 53ab441486 remove unused code 2025-01-05 19:46:22 +08:00
MaysWind 0e422b5a8f migrate settings store to composition API and typescript 2025-01-05 19:45:55 +08:00
MaysWind 4f51480af9 migrate map sheet to composition API and typescript 2025-01-05 17:56:58 +08:00
MaysWind fb8fbbcf70 migrate pin code input sheet to composition API and typescript 2025-01-05 17:46:43 +08:00
MaysWind 5256eff88d code refactor 2025-01-05 17:24:22 +08:00
MaysWind 7c40157cba migrate switch to mobile dialog to composition API and typescript 2025-01-05 17:07:46 +08:00
MaysWind 454e97c9f1 migrate services.js to ts 2025-01-05 17:00:26 +08:00
MaysWind 41d34af4c7 migrate userstat.js to ts 2025-01-05 14:07:47 +08:00
MaysWind a13f5bfb10 support base components folder 2025-01-05 13:53:08 +08:00
MaysWind da06fe4a7b migrate color / icon select to typescript 2025-01-05 13:22:35 +08:00
MaysWind 061ea6aab4 code refactor 2025-01-05 13:14:08 +08:00
MaysWind a19cc81391 code refactor 2025-01-05 13:13:21 +08:00
MaysWind 871164b969 code refactor 2025-01-05 03:12:22 +08:00
MaysWind 16fa77eb09 migrate map code to typescript 2025-01-05 03:12:16 +08:00
MaysWind a46399cbaf migrate setting.js and logger.js to ts 2025-01-04 23:42:57 +08:00
MaysWind a9e50d29d3 fix display incorrect month 2025-01-04 23:34:20 +08:00
MaysWind e7d7f217a9 migrate consts/statistics.js to ts 2025-01-04 23:33:17 +08:00
MaysWind 000c2b9ab0 migrate desktop ui utils to typescript 2025-01-04 20:52:42 +08:00
MaysWind 37fdb161ea migrate information / password input / passcode input sheet to composition API and typescript 2025-01-04 19:29:34 +08:00
MaysWind 2d923bbdc9 migrate confirm dialog to composition API and typescript 2025-01-04 19:29:25 +08:00
MaysWind 07c55de024 migrate steps bar to composition API and typescript 2025-01-04 19:02:37 +08:00
MaysWind 30c463627a code refactor 2025-01-04 19:02:30 +08:00
MaysWind 5eec635146 migrate snack bar to composition API and typescript 2025-01-04 19:02:21 +08:00
MaysWind 229d9c76c3 migrate item icon to composition API and typescript 2025-01-04 17:49:38 +08:00
MaysWind 0119eadc14 code refactor 2025-01-04 17:20:36 +08:00
MaysWind c1c656ab7e add missing type 2025-01-04 17:20:28 +08:00
MaysWind e4a50bcd60 migrate to composition API and typescript 2025-01-04 17:20:00 +08:00
MaysWind d18e6df1c0 pin code input supports pressing enter to confirm and pressing tab to switch to the next component 2025-01-04 16:33:33 +08:00
MaysWind af9aa726f4 migrate pin code input to composition API and typescript 2025-01-04 16:33:18 +08:00
MaysWind 27f8c90dae migrate numeral.js to ts 2025-01-04 14:47:52 +08:00
MaysWind b9a3c384d9 remove unused code 2025-01-04 14:05:24 +08:00
MaysWind eed7085756 code refactor 2025-01-04 14:05:16 +08:00
MaysWind abb0c2ad16 fix cannot click set custom date range in date filter drop menu 2025-01-04 00:48:50 +08:00
MaysWind 9f7b40381c migrate lib/datetime.js to ts 2025-01-04 00:18:35 +08:00
MaysWind ad9a390b58 migrate consts/datetime.js to ts 2025-01-02 00:39:56 +08:00
MaysWind 5525635df1 bump year 2025-01-01 01:43:26 +08:00
MaysWind 863e0205ff code refactor 2024-12-30 23:11:03 +08:00
MaysWind 2560a70e5e migrate to typescript 2024-12-30 00:56:48 +08:00
MaysWind b638a73e4d modify style 2024-12-29 22:46:48 +08:00
MaysWind 0a9cea4df3 do not check whether the date range is billing cycle when no statement date 2024-12-23 08:50:24 +08:00
MaysWind 15a98c3eac add missing router path 2024-12-22 22:05:05 +08:00
MaysWind 493c16087d add missing router path 2024-12-22 21:52:56 +08:00
MaysWind dd155a0f63 add gitea deploy action 2024-12-22 17:21:53 +08:00
MaysWind 9ce1c8d397 upgrade third party dependencies 2024-12-22 12:09:06 +08:00
MaysWind 3040435c06 upgrade third party dependencies 2024-12-22 11:48:34 +08:00
MaysWind 685f93f05e upgrade node.js to 22.12.0 2024-12-22 11:34:23 +08:00
MaysWind d465d9da1a add sample case for not showing currency option 2024-12-22 11:25:23 +08:00
MaysWind 50c2766014 format file 2024-12-22 00:09:17 +08:00
MaysWind 4e1cbf13c6 bump version to 0.8.0 2024-12-21 23:57:06 +08:00
MaysWind a26397131d check whether the billing cycle is chosen when set custom date range or backward/forward the date range 2024-12-21 23:15:14 +08:00
MaysWind 7659e8f0f7 set date range type to custom when switching account and the statement date of two accounts are different 2024-12-21 22:30:59 +08:00
MaysWind 90b608bdc6 show confirm dialog before switching to desktop version 2024-12-21 22:23:06 +08:00
MaysWind fffe2a1ccb code refactor 2024-12-21 22:13:00 +08:00
MaysWind fd7706de6d remove redundant code 2024-12-21 22:12:26 +08:00
Huỳnh Đức Khoản d0a5c93e49 fix replace all with special characters 2024-12-20 16:55:15 +08:00
MaysWind 263bf08f34 fix typo 2024-12-18 23:11:27 +08:00
MaysWind e050f30efa code refactor 2024-12-18 22:46:05 +08:00
MaysWind c2b1adf588 upgrade golang to 1.23.4, node.js to 20.18.1, alpine base image to 3.21.0 2024-12-18 22:32:37 +08:00
MaysWind 647cd3c33f add billing cycle date range filter 2024-12-16 23:44:20 +08:00
MaysWind 8fdbb39ee4 add date time filter dropdown menu in desktop transaction list page 2024-12-15 23:49:09 +08:00
MaysWind ee029294f1 improve robustness 2024-12-12 08:54:19 +08:00
MaysWind 563e328ce3 sub account cannot set statement date 2024-12-11 23:53:01 +08:00
MaysWind 8f543d7a84 update translation 2024-12-11 23:30:25 +08:00
MaysWind 62e09190f3 credit card account supports statement date 2024-12-10 22:41:06 +08:00
MaysWind 50c774fd78 show add tag button in tag selection sheet when there are no available tags 2024-12-08 22:38:07 +08:00
MaysWind 10e4bcc723 skip specified tests when build snapshot image 2024-12-08 22:20:16 +08:00
MaysWind 964ad6d046 modify style 2024-12-08 21:16:12 +08:00
MaysWind 56fb76017d modify style 2024-12-08 21:16:00 +08:00
MaysWind 5a9141e10c add new tag in transaction edit page / dialog 2024-12-08 21:04:42 +08:00
MaysWind db94282207 statistics analysis supports filtering tags 2024-12-08 18:00:46 +08:00
MaysWind 9f6446c30c make all tags unselected in tag select page / dialog when no tags are selected 2024-12-08 16:29:20 +08:00
MaysWind d570ce361d add missing code 2024-12-08 16:22:11 +08:00
MaysWind 868fcf2c5a code refactor 2024-12-08 13:14:03 +08:00
MaysWind dd35a85316 support transaction tag filter type 2024-12-08 00:43:29 +08:00
MaysWind 5003f8b3a2 not set destination amount automatically when lack of exchange rates data 2024-12-07 16:54:45 +08:00
MaysWind d044f938e3 skip calculating exchange rates when the account balance is 0 2024-12-07 16:48:28 +08:00
MaysWind e549779164 display amounts according to currency decimals number count 2024-12-06 23:56:02 +08:00
MaysWind e2f2b325a6 update language name 2024-12-06 23:16:06 +08:00
MaysWind 9860c1db54 modify style 2024-12-04 22:44:45 +08:00
MaysWind 7d820f5b88 apply the selected legends when jumping to the transaction list page 2024-12-04 22:36:30 +08:00
MaysWind 61d6e5643c modify style 2024-12-04 08:07:47 +08:00
MaysWind b444de591a merge aggregated data items 2024-12-03 23:05:24 +08:00
MaysWind 21c86c9dfa toggle legend for trends bar chart 2024-12-03 22:42:35 +08:00
MaysWind 8e70754533 remove unused code 2024-12-03 22:06:52 +08:00
MaysWind 4270d74338 update timezone info 2024-12-03 21:57:41 +08:00
Huỳnh Đức Khoản db506fa992 Update vi.json 2024-12-03 12:36:14 +08:00
Huỳnh Đức Khoản c1b06eaa6f Update formater 2024-12-03 12:36:14 +08:00
Huỳnh Đức Khoản 6bd1d09fa8 Add Vietnamese
Update index.js

Update vi.json
2024-12-03 12:36:14 +08:00
MaysWind 70da228dcc show legend in trend analysis for mobile version 2024-12-03 00:03:12 +08:00
MaysWind 9888efe437 modify style 2024-12-02 22:18:46 +08:00
MaysWind 65756b62a5 add trend analysis for mobile version 2024-12-01 23:56:42 +08:00
MaysWind 59a0d593d4 code refactor 2024-12-01 23:00:14 +08:00
MaysWind d519b80b61 reset date aggregation type for trend analysis when switching to non-trend analysis 2024-11-24 23:43:54 +08:00
MaysWind e92725f38b add the Central Bank of the Republic of Uzbekistan exchange rates data source 2024-11-18 01:01:18 +08:00
MaysWind ec0cb0bbb7 improve robustness and add unit tests 2024-11-18 01:00:57 +08:00
MaysWind a4b26374f4 add Central Bank of Myanmar exchange rates data source 2024-11-17 22:07:53 +08:00
MaysWind dcac6a4bb0 add Central Bank of Hungary exchange rates data source 2024-11-17 21:29:57 +08:00
MaysWind dd6eecb0c2 add debug log 2024-11-17 21:27:22 +08:00
MaysWind fec100a273 use the local language to show the national bank name 2024-11-17 13:16:22 +08:00
MaysWind 8f944b1b46 code refactor 2024-11-17 13:04:07 +08:00
MaysWind 69498003d8 add Bank of Russia exchange rates data source 2024-11-17 01:31:25 +08:00
MaysWind e019f557ff remove unused data in test cases 2024-11-17 01:29:30 +08:00
MaysWind 4b5611ef6c use reader label charset reader for xml deserializing 2024-11-17 01:04:06 +08:00
MaysWind ca44b2cc2c add exchange rates api unit tests 2024-11-17 00:23:49 +08:00
MaysWind 10e0972d79 add Norges Bank exchange rates data source 2024-11-16 23:28:19 +08:00
MaysWind 28908d81a3 change timezone name 2024-11-16 22:35:20 +08:00
MaysWind 0503a50754 change timezone name 2024-11-16 22:34:44 +08:00
MaysWind 65a92042d6 increase the request timeout in frontend if the timeout of requesting third-party exchange rates api exceeds the default time 2024-11-16 21:13:37 +08:00
MaysWind f554fdefd3 add National Bank of Georgia exchange rates data source 2024-11-16 20:51:11 +08:00
MaysWind bdbd4d5302 add National Bank of Romania exchange rates data source 2024-11-16 15:08:34 +08:00
MaysWind 3ee1683349 add unit tests 2024-11-16 15:07:32 +08:00
MaysWind 3a7ad429c2 add unit tests and improve robustness 2024-11-16 14:58:18 +08:00
MaysWind 89bd055f02 add logs 2024-11-15 00:40:14 +08:00
MaysWind 835b3b7b8b not need balance time field in parent account 2024-11-15 00:34:46 +08:00
MaysWind 934f90cdff sort currencies in exchange rates page 2024-11-15 00:22:05 +08:00
MaysWind 92cc683b8e add Danmarks Nationalbank exchange rates data source 2024-11-14 23:46:07 +08:00
MaysWind 80d548e8bd add Swiss National Bank exchange rates data source 2024-11-13 01:46:03 +08:00
MaysWind 7ec1efb85d code refactor 2024-11-13 00:48:35 +08:00
MaysWind f5945a788f add unit tests 2024-11-13 00:08:39 +08:00
MaysWind 2d0e2e0cca add Bank of Israel exchange rates data source 2024-11-12 01:20:14 +08:00
MaysWind bff6ca7e9d support setting the time of the initial balance when creating a new account 2024-11-11 01:27:44 +08:00
MaysWind 06b4960984 fix the failure of creating account with initial balance sometimes 2024-11-11 00:35:33 +08:00
MaysWind 2fe393204b fix the issue that the "all" tab of account with multiple sub accounts are not selected when opening the account list page in desktop version 2024-11-10 23:49:34 +08:00
MaysWind 876950a84e only show date aggregation menu in trend analysis 2024-11-10 21:57:04 +08:00
MaysWind 6292ef9dfb add unit tests 2024-11-10 21:35:31 +08:00
MaysWind 798fb8f937 add unit tests 2024-11-10 21:35:19 +08:00
MaysWind f6dd4c03c3 support custom tips in login page 2024-11-10 20:50:03 +08:00
MaysWind f87fbddef7 code refactor 2024-11-10 17:54:32 +08:00
MaysWind aa2e10440d support modifying user feature restriction by cli 2024-11-10 15:48:32 +08:00
MaysWind 34b0b793ba support default feature restrictions after user registration 2024-11-10 15:24:09 +08:00
MaysWind 1f159bf826 support user features restrictions 2024-11-10 01:44:58 +08:00
MaysWind b8253b6dcc create user token via cli 2024-11-09 23:43:28 +08:00
MaysWind 79fd9070e4 make the time range not exceed the selected range when jumping from trend analysis chart to transaction list page 2024-11-08 17:59:18 +08:00
MaysWind 7b96cd0447 remove unused code 2024-11-08 17:48:57 +08:00
MaysWind 01bc9becc0 code refactor 2024-11-08 17:47:51 +08:00
MaysWind 9a009b73dc fix the incorrect parameter when jumping from the statistics page to the transaction list page 2024-11-08 14:20:49 +08:00
MaysWind fe35cbae49 trend analysis supports aggregating amounts by month / quarter / year 2024-11-06 01:35:42 +08:00
MaysWind c3a880e5f5 keep the day of the month when shifting the date range forward or backward if the selected date range is a full month 2024-11-05 00:55:22 +08:00
MaysWind 1c906113ab remove unused code 2024-11-05 00:17:43 +08:00
MaysWind 6f3dcd958d upgrade third party dependencies 2024-11-04 22:42:33 +08:00
MaysWind 7a9f4cd64f upgrade third party dependencies 2024-11-04 00:37:32 +08:00
MaysWind 9a67af7c55 code refactor 2024-11-04 00:37:04 +08:00
MaysWind 501de6ffef bump version to 0.7.0 2024-11-03 21:11:18 +08:00
MaysWind 210d978279 support filtering transactions in import transaction dialog 2024-11-03 20:59:00 +08:00
MaysWind a35771acc4 fix cannot batch replace tag in import transaction dialog sometimes 2024-11-03 20:50:28 +08:00
MaysWind 637faef690 upgrade golang to 1.22.8, node.js to 20.18.0, alpine base image to 3.20.3 2024-11-03 00:42:06 +08:00
MaysWind c800eb5d4d change transaction type of credit card payment to transfer transaction 2024-11-03 00:05:12 +08:00
MaysWind 0e062ed065 code refactor 2024-11-02 22:47:33 +08:00
MaysWind f2e89da724 support ofx 1.x 2024-11-02 02:05:55 +08:00
MaysWind ac29f0bf98 read and check ofx 2.x file header 2024-11-01 00:11:26 +08:00
MaysWind d174e99c80 code refactor 2024-10-31 00:57:34 +08:00
MaysWind 5006a96181 parse xml using the encoding declared in xml header 2024-10-30 23:51:07 +08:00
MaysWind ce8c020477 support transfer in transaction 2024-10-30 23:42:17 +08:00
MaysWind 98c96b8217 support qpf file extension 2024-10-30 22:51:41 +08:00
MaysWind 43404adf49 code refactor 2024-10-30 22:45:44 +08:00
MaysWind 90ea462206 code refactor 2024-10-30 01:05:04 +08:00
MaysWind 92a78f6f12 add unit tests 2024-10-30 00:55:52 +08:00
MaysWind be7fbd405e add comment 2024-10-30 00:45:01 +08:00
MaysWind 98e3c6ebfd add unit test 2024-10-30 00:40:58 +08:00
MaysWind fbca205cca add unit test and improve robustness 2024-10-30 00:30:33 +08:00
MaysWind d3c25a1aff code refactor 2024-10-29 23:39:58 +08:00
MaysWind 84f2778bc0 add comment 2024-10-29 23:39:01 +08:00
MaysWind 688185c367 add comment 2024-10-29 23:33:39 +08:00
MaysWind bf48bfdd7c add unit tests 2024-10-29 23:15:02 +08:00
MaysWind bde0b01d06 add unit tests 2024-10-29 00:56:46 +08:00
MaysWind a1b7c8ad1d check whether amount is less than 0 for transfer transaction 2024-10-29 00:50:37 +08:00
MaysWind 37ff0d1fab check whether split quantity node exists 2024-10-29 00:38:24 +08:00
MaysWind 0c218df3ad use extrame/xls library to replace xlsReader 2024-10-29 00:35:10 +08:00
MaysWind 259f27bf1b support parsing account to 2024-10-28 23:55:34 +08:00
MaysWind f2bc8e44fc add unit test and improve robustness 2024-10-28 23:46:45 +08:00
MaysWind c44bf73b42 add unit test 2024-10-28 22:59:46 +08:00
MaysWind fbd19f9da4 support comma as decimal point 2024-10-28 22:44:03 +08:00
MaysWind 50fc0783d4 change type name 2024-10-28 22:43:17 +08:00
MaysWind c372272394 import transactions from ofx 2.x file 2024-10-28 01:39:03 +08:00
MaysWind 22d653cc76 add unit test 2024-10-27 23:52:06 +08:00
MaysWind 46dbfcbe77 use name column as description 2024-10-27 23:50:52 +08:00
MaysWind 91d51e660b support parsing amount with thousandss eparator 2024-10-27 17:21:49 +08:00
MaysWind 76f5f12563 support year/month/day format date 2024-10-27 17:15:59 +08:00
MaysWind 08bc0eff8c not required transaction type column 2024-10-27 17:09:23 +08:00
MaysWind be1d219fea update Chinese transaction 2024-10-27 16:02:01 +08:00
MaysWind 52034ef55c code refactor 2024-10-27 11:21:23 +08:00
MaysWind 47ab41088e change csv and tsv to subtypes when importing ezbookkeeping exported files in import transaction dialog 2024-10-27 01:54:26 +08:00
MaysWind fb5484f44d import transactions from iif file 2024-10-27 01:22:07 +08:00
MaysWind cfbab0432c fix npe 2024-10-26 22:44:42 +08:00
MaysWind 34bf74da84 fix typo 2024-10-26 19:48:24 +08:00
MaysWind 889f90015a remove redundant code 2024-10-26 19:16:37 +08:00
MaysWind 1d0817b1b3 fix parsing amount for expense transaction 2024-10-22 00:22:57 +08:00
MaysWind 54150a9157 modify anchor name 2024-10-21 22:24:54 +08:00
MaysWind a8a89ca089 modify anchor name 2024-10-21 01:08:53 +08:00
MaysWind bb4eca1b0c import transaction from GnuCash database 2024-10-21 01:02:37 +08:00
MaysWind 6ce6fd3aa8 modify parameter name 2024-10-20 23:25:57 +08:00
MaysWind 35ec18cfac code refactor 2024-10-20 21:38:51 +08:00
MaysWind 3795e788bb change display order 2024-10-20 19:21:49 +08:00
MaysWind 4b239030c5 improve the compatibility of qif files 2024-10-20 15:56:19 +08:00
MaysWind 7162ce4a77 fix the wrong amount and account name of transfer in transaction 2024-10-20 15:47:30 +08:00
MaysWind 03f0e4a477 modify error message 2024-10-20 10:12:00 +08:00
MaysWind a23a194660 add subtypes to imported file types 2024-10-20 10:05:44 +08:00
MaysWind 45faa269a4 add comment 2024-10-20 01:54:03 +08:00
MaysWind 981a1aac4f import transaction from qif file 2024-10-20 01:47:54 +08:00
MaysWind 70ccf7b691 code refactor 2024-10-19 19:18:03 +08:00
MaysWind 8bc763be9b code refactor 2024-10-18 00:53:10 +08:00
MaysWind 815bb08fa9 fix wrong log 2024-10-18 00:20:17 +08:00
MaysWind 34773537c2 code refactor 2024-10-17 01:13:35 +08:00
MaysWind 6c285a0856 modify function name 2024-10-16 23:18:12 +08:00
MaysWind a062592043 rename files 2024-10-16 00:46:30 +08:00
MaysWind 8978e340c7 add unit test 2024-10-15 00:33:59 +08:00
MaysWind 07c1bba829 code refactor 2024-10-15 00:33:45 +08:00
MaysWind 4f836f5e3a get function of writable transaction data table supports data row parser, column count and get data function of writable transaction data row supports filtering defined columns add related unit tests 2024-10-14 23:57:26 +08:00
MaysWind 2cfc24a808 update log content 2024-10-14 23:55:41 +08:00
MaysWind 07743368f4 code refactor 2024-10-14 23:13:50 +08:00
MaysWind 592c04c5ab code refactor 2024-10-14 23:11:54 +08:00
MaysWind bb8a72876b code refactor 2024-10-14 23:09:17 +08:00
MaysWind b9b501edfa import transaction from wechat pay billing file 2024-10-14 01:21:41 +08:00
MaysWind 44fe7778b6 code refactor 2024-10-14 00:31:09 +08:00
MaysWind 6ea5ad1619 change file type name 2024-10-13 23:49:38 +08:00
MaysWind d9b819d1a1 code refactor 2024-10-13 19:44:29 +08:00
MaysWind 5ac9eb5d5c fix cannot import transaction from firefly iii when only have minimal required columns, add unit tests 2024-10-12 23:52:42 +08:00
MaysWind 1345603e09 update documents anchor 2024-10-12 22:19:53 +08:00
MaysWind bd66408c3d import transaction from firefly iii 2024-10-12 01:17:56 +08:00
MaysWind f75e078fed code refactor 2024-10-11 00:40:39 +08:00
MaysWind 09fc82f7b7 code refactor 2024-10-11 00:03:21 +08:00
MaysWind 7bc9a0357e code refactor 2024-10-10 23:53:11 +08:00
MaysWind dc6420ccb0 add unit test 2024-10-10 01:03:10 +08:00
MaysWind fadf72c245 modify file name 2024-10-09 01:36:44 +08:00
MaysWind c8ff60d986 fix account balance calculation error after modify the amount of balance modification transaction 2024-10-09 01:36:36 +08:00
MaysWind e5cd8ffa61 not allow to add transaction before balance modification transaction and not allow to modify transaction time for balance modification transaction 2024-10-09 01:35:48 +08:00
MaysWind c36f58e491 add documents link 2024-10-09 00:25:00 +08:00
MaysWind 45d348c0ef support importing transaction data from alipay app 2024-10-08 00:23:22 +08:00
MaysWind ae26f00a36 code refactor 2024-10-07 23:54:50 +08:00
MaysWind a6e765f51c show export data guide in import transaction dialog 2024-10-07 20:54:23 +08:00
MaysWind 3c428ade52 modify text 2024-10-07 20:02:31 +08:00
MaysWind 011020a945 show language name in current language 2024-10-07 19:58:58 +08:00
MaysWind 368322f906 initial display mode of the date time sheet in mobile version depends on the click content (#28) 2024-09-26 00:46:42 +08:00
MaysWind a3ff181b6e not allow to input non number to the amount input box in desktop version via mobile device 2024-09-25 23:17:55 +08:00
MaysWind 720a5f8897 modify style 2024-09-25 22:40:50 +08:00
MaysWind 633cb44db6 code refactor 2024-09-25 22:38:38 +08:00
MaysWind 73f234d8f5 not allow to click continue button or batch replace menu when there is an editing transaction, only update the display transaction content after clicking the confirm button 2024-09-24 01:29:46 +08:00
MaysWind a49490baa7 support categories with the same name but different types when import transaction 2024-09-24 01:02:05 +08:00
MaysWind a90f08a85f support multi sort in import dialog 2024-09-24 00:22:35 +08:00
MaysWind 17ee037525 modify style 2024-09-24 00:13:07 +08:00
MaysWind 75aa55d340 fix the sorting order does not change after modifying category or account 2024-09-23 23:57:30 +08:00
MaysWind d32cd793d0 support batch replace category / account / tag in import transaction dialog 2024-09-23 23:47:02 +08:00
MaysWind 29781bbac4 support batch replace category / account / tag in import transaction dialog 2024-09-23 00:19:03 +08:00
MaysWind 21ea36a4f7 code refactor 2024-09-22 23:39:01 +08:00
MaysWind 5e99b9d555 remove unused code 2024-09-22 21:59:14 +08:00
MaysWind 3190608d36 support choosing invalid items in import transaction dialog 2024-09-22 21:50:41 +08:00
MaysWind 4d0aecb8c2 modify style 2024-09-22 21:35:01 +08:00
MaysWind e1f420c3ae add dropdown page select box in import transaction dialog 2024-09-22 21:31:48 +08:00
MaysWind cbf3dd9776 modify style 2024-09-22 19:48:47 +08:00
MaysWind 0ff97ac4e0 check error message 2024-09-22 19:34:22 +08:00
MaysWind 52b37c2a13 support importing transaction data from alipay export file 2024-09-22 19:27:21 +08:00
MaysWind 732fa3b9de code refactor 2024-09-22 16:46:35 +08:00
MaysWind 4047aaf48a add comment 2024-09-22 16:46:27 +08:00
MaysWind bc3e7ae29b show file extensions in import dialog 2024-09-22 16:46:19 +08:00
MaysWind ed87e56a33 code refactor 2024-09-22 16:46:01 +08:00
MaysWind 28ce1e856c modify style 2024-09-22 15:01:29 +08:00
MaysWind 4c13b7ad02 automatically save transaction draft 2024-09-22 14:41:35 +08:00
MaysWind 49df497f35 code refactor 2024-09-20 23:38:43 +08:00
MaysWind 5221ab481e show a confirmation dialog allowing the user to change the editable transaction range to "All" when unable to add or edit a transaction due to the limitation on the editable transaction range 2024-09-20 00:47:53 +08:00
MaysWind 6655d725ae code refactor 2024-09-20 00:10:40 +08:00
MaysWind 220f9f15e5 modify the name of debit card to checking account, add savings account and certificate of deposit 2024-09-20 00:00:19 +08:00
MaysWind 1e8a27612f code refactor 2024-09-19 23:07:12 +08:00
MaysWind 7ecec2bb64 code refactor 2024-09-19 00:03:07 +08:00
MaysWind fceb92eb6f change the type of balance modification transaction to income or expense for imported feidee mymoney transaction data 2024-09-18 23:04:00 +08:00
MaysWind 8b92051900 update splash screen images for ios 2024-09-17 20:49:02 +08:00
MaysWind 03f3e039e0 add more conditions for sorting of ImportTransaction 2024-09-17 19:40:08 +08:00
MaysWind 18ebf7baaf code refactor 2024-09-17 19:40:02 +08:00
MaysWind 20b28f2a68 support importing transaction data from feidee mymoney app export data 2024-09-17 19:39:50 +08:00
MaysWind 6d0fdc6860 code refactor 2024-09-17 17:06:13 +08:00
MaysWind 9f0e82446e update comments 2024-09-17 09:48:01 +08:00
MaysWind cb69991f7f update documents 2024-09-13 20:46:24 +08:00
MaysWind 327fdd66e4 update unit test 2024-09-12 00:10:05 +08:00
MaysWind 7d01b4bd5a modify style 2024-09-12 00:09:54 +08:00
MaysWind d15a862e5b support importing feidee mymoney web export data 2024-09-12 00:08:28 +08:00
MaysWind 5a31118c96 code refactor 2024-09-11 23:51:47 +08:00
MaysWind 8eaeb1953b modify style 2024-09-11 23:04:18 +08:00
MaysWind 25674c04c8 allow import file without sub category name 2024-09-11 02:09:12 +08:00
MaysWind cd6e7c81e5 code refactor 2024-09-11 01:40:42 +08:00
MaysWind d915de8ff9 allow import file does not include transfer in amounts 2024-09-11 01:38:57 +08:00
MaysWind 1307d49762 code refactor 2024-09-11 01:08:43 +08:00
MaysWind 2cffd4fbbb code refactor 2024-09-11 00:57:16 +08:00
MaysWind 031209490f add more error hints 2024-09-11 00:48:18 +08:00
MaysWind 5d75629a73 code refactor 2024-09-11 00:25:11 +08:00
MaysWind 27c4afd41b code refactor 2024-09-10 23:31:57 +08:00
MaysWind 9db4a2430a modify style 2024-09-10 01:04:17 +08:00
MaysWind e1ac3732bd improve performance 2024-09-10 00:37:22 +08:00
MaysWind 56ad572387 code refactor 2024-09-10 00:32:36 +08:00
MaysWind 70beb45c4e add logs 2024-09-10 00:23:31 +08:00
MaysWind 698c0a62a2 support import transaction tags 2024-09-10 00:16:06 +08:00
MaysWind 8421649bcc modify style 2024-09-10 00:02:16 +08:00
MaysWind e8883781e5 update timeout 2024-09-09 23:32:42 +08:00
MaysWind 77e9ae94cf code refactor 2024-09-09 23:26:08 +08:00
MaysWind 30344ef5cb disable touch for v-window 2024-09-09 22:29:59 +08:00
MaysWind fa0460abd0 update eslint config 2024-09-09 01:39:41 +08:00
MaysWind ee52db3f7c make tags of exported transaction data in the same order as they displayed in transaction 2024-09-09 01:34:38 +08:00
MaysWind 00c8259bd0 update timeout 2024-09-09 01:34:27 +08:00
MaysWind 470a74f420 support importing transaction in frontend 2024-09-09 01:34:14 +08:00
MaysWind 3d5a03a629 code refactor 2024-09-08 23:56:21 +08:00
MaysWind cc8646cf1b add log 2024-09-07 22:31:30 +08:00
MaysWind 308c89aa0b skip empty line 2024-09-07 21:47:32 +08:00
MaysWind de37c3da5a code refactor 2024-09-07 21:06:01 +08:00
MaysWind 593b924f32 add comment 2024-09-07 14:29:02 +08:00
MaysWind bc3cb79f91 code refactor 2024-09-07 03:01:57 +08:00
MaysWind 9622d5de06 limit the maximum size of upload pictures 2024-09-06 23:34:35 +08:00
MaysWind 2dddb77ca4 fix there are unnecessary separators in exported file when the tag in transaction does not exist, fix the incorrect exported data when the content contains CR("\r") 2024-09-03 00:47:47 +08:00
MaysWind 1d43eda9b7 add unit test 2024-09-03 00:16:41 +08:00
MaysWind dbb1843285 code refactor 2024-09-02 23:37:33 +08:00
MaysWind dfe1b853d1 code refactor 2024-09-02 23:36:54 +08:00
MaysWind c7e4d4eaae add log 2024-09-02 23:31:43 +08:00
MaysWind 7c59e8386e support importing transaction by csv/tsv file via command line 2024-09-02 01:40:04 +08:00
MaysWind 366311edbb fix incorrect transaction amount in exported data 2024-09-01 22:15:56 +08:00
MaysWind 2fc6a6ca77 modify style 2024-09-01 17:52:18 +08:00
MaysWind 9945cb7a94 modify style 2024-09-01 12:08:26 +08:00
MaysWind 50918756d7 modify style 2024-09-01 12:06:43 +08:00
MaysWind 43c37763d8 code refactor 2024-09-01 00:49:06 +08:00
MaysWind 4e365f54af code refactor 2024-09-01 00:43:40 +08:00
MaysWind 09ddf53b01 limit maximum count of tags in a transaction 2024-09-01 00:33:11 +08:00
MaysWind 7fbfa71434 support transaction pictures 2024-09-01 00:30:56 +08:00
MaysWind ae46cd2332 fix other transactions could not be created with the same transaction time of the deleted transaction 2024-08-31 15:40:22 +08:00
MaysWind 772a22a182 add transaction pictures api 2024-08-31 15:34:24 +08:00
MaysWind 636ac974b8 show transaction pictures in data management page 2024-08-30 21:58:25 +08:00
MaysWind 216c8211ac code refactor 2024-08-30 21:57:06 +08:00
MaysWind 805d3e65e3 update comment 2024-08-30 00:40:59 +08:00
MaysWind 73c69c3761 add transaction picture upload api 2024-08-30 00:33:48 +08:00
MaysWind fe442f27f2 update comment 2024-08-29 22:44:05 +08:00
MaysWind 8b51f6ebaa add unit tests 2024-08-28 23:42:13 +08:00
MaysWind ab745ad56b add International Monetary Fund exchange rates data source 2024-08-28 01:12:16 +08:00
MaysWind 62d3dc63d1 update comments 2024-08-28 00:08:23 +08:00
MaysWind fcfd9894a3 fix wrong timezone in template edit page / dialog 2024-08-27 23:34:40 +08:00
MaysWind df076b563a add tooltip to notification 2024-08-27 23:13:27 +08:00
MaysWind 366fbff012 improve auto scroll for multi-selected 2024-08-27 23:02:42 +08:00
MaysWind d8f7175da9 code refactor 2024-08-27 22:53:36 +08:00
MaysWind 2bd3845d22 update comments 2024-08-27 22:41:38 +08:00
MaysWind 720f83bd0b show multiple selected week days in the order of the week day list 2024-08-26 23:32:12 +08:00
MaysWind c2fbd918dd limit the count of tags in transaction template 2024-08-26 23:21:06 +08:00
MaysWind 902361e5d6 fix wrong time zone minutes offset for Marquesas Islands and Newfoundland 2024-08-26 23:08:07 +08:00
MaysWind 5a2576b368 add unit tests 2024-08-26 22:56:32 +08:00
MaysWind d71014a797 hide scheduled transaction in settings page when scheduled transaction is not enabled 2024-08-26 22:41:57 +08:00
MaysWind d2eaf5c6da support scheduled transaction (#2) 2024-08-26 02:30:16 +08:00
MaysWind 17d4fec256 modify variable name 2024-08-20 00:37:03 +08:00
MaysWind 4a96bac457 don't show the editable icon when user avatar cannot be modified 2024-08-19 23:56:03 +08:00
MaysWind 4977979b08 code refactor 2024-08-19 23:47:55 +08:00
MaysWind 8fa19df113 add log 2024-08-19 22:57:08 +08:00
MaysWind 217d37e3d3 fix gocron log format 2024-08-19 01:10:28 +08:00
MaysWind e86d4e05ce code refactor 2024-08-19 00:35:45 +08:00
MaysWind 6fcb0a2b3c remove deprecated Monetary Authority of Singapore exchange rates api 2024-08-18 00:43:02 +08:00
MaysWind 560edf9fbf code refactor 2024-08-17 00:38:59 +08:00
MaysWind e532f372b5 code refactor 2024-08-16 21:01:35 +08:00
MaysWind 1101796641 support number pad keys in pin code input and amount input 2024-08-13 02:08:42 +08:00
MaysWind c2757f68a6 code refactor and add unit tests 2024-08-13 01:29:51 +08:00
MaysWind d648226d13 add unit tests 2024-08-12 23:52:05 +08:00
MaysWind 4987819227 remove unused code 2024-08-12 23:21:07 +08:00
MaysWind 8d27997e2e fix typo 2024-08-12 22:49:07 +08:00
MaysWind b9ee94a47d remove unused code 2024-08-12 22:47:30 +08:00
MaysWind 8b531cc726 fix the invalid month in the mobile version transaction list page 2024-08-12 22:44:48 +08:00
MaysWind 753fc762a0 remove unused code 2024-08-12 01:14:52 +08:00
MaysWind 80396a444e fix the page could not load properly when selecting the same date in trend analysis 2024-08-12 01:06:03 +08:00
MaysWind 52dfee9ca6 support periodically cleaning up expired tokens 2024-08-12 00:49:07 +08:00
MaysWind 80b8b9afdd change parameter name 2024-08-11 21:11:14 +08:00
MaysWind 20dc72022d code refactor 2024-08-11 20:36:32 +08:00
MaysWind 9116f404db upgrade third party dependencies 2024-08-11 17:13:37 +08:00
MaysWind 029e5f6d02 upgrade golang to 1.22, node.js to v20, alpine base image to 3.20.2 2024-08-11 12:48:43 +08:00
MaysWind 6ccaf89d86 fix the user avatar in the top right corner does not update in the desktop version 2024-08-11 12:48:43 +08:00
MaysWind 0d706abbd3 don't remove old user custom avatar when old custom avatar type is empty 2024-08-11 12:48:43 +08:00
MaysWind f4a27e59a3 check whether query is valid before query user from database 2024-08-11 12:48:43 +08:00
MaysWind 5ab7d0e9b3 bump version to 0.6.0 2024-08-11 12:48:11 +08:00
MaysWind d198634326 bump version to 0.6.0 2024-08-11 00:22:48 +08:00
MaysWind d3762e6c46 add storage directory in built package 2024-08-10 19:08:56 +08:00
MaysWind 157eb140eb hide multiple accounts / categories item when there are no accounts / categories 2024-08-10 00:40:03 +08:00
MaysWind 83a0c27259 hide secret in boot log 2024-08-10 00:10:15 +08:00
MaysWind 5c4a8e37c4 verify whether required items is valid before submitting new transaction 2024-08-09 00:18:52 +08:00
MaysWind e4faf64ea3 redesign the default request id generator, replace random number to client port 2024-08-09 00:07:32 +08:00
MaysWind a4849fa4f0 add notification when user registers 2024-08-08 22:29:01 +08:00
MaysWind 32155ca63d code refactor 2024-08-08 22:26:57 +08:00
MaysWind 9ea3327517 don't create log file if request log or query log are not enabled 2024-08-08 22:18:56 +08:00
MaysWind 946a7810a7 support logging request logs and database query logs to separate files, and support rotating log files 2024-08-08 02:03:57 +08:00
MaysWind ea8021b359 modify log format 2024-08-07 22:47:56 +08:00
MaysWind caa27841ef fix the incorrect calculation of monthly income and expense amount when filtering multiple accounts 2024-08-07 00:54:57 +08:00
MaysWind d1cd13723a add online demo url 2024-08-06 01:11:57 +08:00
MaysWind 0e946a4b3b show notification in frontend 2024-08-06 00:40:27 +08:00
MaysWind 051c319890 use client language if user language is set to system default 2024-08-05 23:58:52 +08:00
MaysWind f2baa4ae65 code refactor 2024-08-05 01:29:53 +08:00
MaysWind 05a93667eb display notification every time users open the app or login 2024-08-05 01:25:26 +08:00
MaysWind c137156c97 code refactor 2024-08-05 01:15:33 +08:00
MaysWind 8f8a94cd66 add comment 2024-08-05 00:41:46 +08:00
MaysWind 4cdb599bf3 add tips 2024-08-05 00:22:58 +08:00
MaysWind 77a5ccd796 support editing templates for mobile version 2024-08-04 23:28:15 +08:00
MaysWind 99ae18d06d code refactor 2024-08-04 19:51:21 +08:00
MaysWind 90318d5690 fix adding transaction bug on desktop version 2024-08-04 19:50:33 +08:00
MaysWind f133692002 check whether input is valid before submitting 2024-08-04 19:50:26 +08:00
MaysWind 0b17251f94 modify style 2024-08-04 19:50:17 +08:00
MaysWind 24724bb19f show transaction template count in data management page 2024-08-04 19:50:04 +08:00
MaysWind b23d630daa code refactor 2024-08-04 16:21:13 +08:00
MaysWind fcb954ff40 support creating transaction from template 2024-08-04 01:07:50 +08:00
MaysWind 889225301c add translating guide link 2024-08-03 21:15:33 +08:00
MaysWind 816d0e7ceb code refactor 2024-08-03 19:24:59 +08:00
MaysWind f2c0ffab99 code refactor 2024-08-03 19:11:30 +08:00
MaysWind f1f61a9038 code refactor 2024-08-03 18:21:04 +08:00
MaysWind 77439f675b code refactor 2024-08-03 17:04:29 +08:00
MaysWind c57f17233a support currency unit name 2024-08-03 16:54:54 +08:00
MaysWind c91a56547f update currency symbols and currency symbol supports plural symbol 2024-08-03 16:06:59 +08:00
MaysWind 10dc2d1713 update currency names 2024-08-03 15:38:06 +08:00
MaysWind d3c8a520ca update latest currencies 2024-08-02 00:57:23 +08:00
MaysWind 681f888529 fix the amount in transaction edit dialog may not display after unhiding the amount 2024-08-01 00:54:55 +08:00
MaysWind bce8c23b05 fix the incorrect range of editable transaction range 2024-08-01 00:45:57 +08:00
MaysWind 788bfa7d4b fix the default user avatar not display when user cannot load user avatar url 2024-08-01 00:39:00 +08:00
MaysWind 6d331c873b check whether account category is valid when creating account 2024-08-01 00:17:17 +08:00
MaysWind a03df7ed36 create transaction template and modify template name in edit dialog 2024-08-01 00:04:07 +08:00
MaysWind b0b330903c transaction template supports setting whether hide amount 2024-07-30 00:08:48 +08:00
MaysWind 4e16f963a8 modify style 2024-07-29 23:56:33 +08:00
MaysWind 8ef4b5537c display a tooltip icon when hover the avatar 2024-07-29 23:54:52 +08:00
MaysWind 8273e06e43 code refactor 2024-07-29 23:08:04 +08:00
MaysWind b91305c490 modify setting key 2024-07-29 22:37:59 +08:00
MaysWind de086aa29e add transaction template 2024-07-29 01:27:11 +08:00
MaysWind 4c69243bef modify log 2024-07-28 20:16:02 +08:00
MaysWind 57d8edea0a modify index 2024-07-28 18:32:59 +08:00
MaysWind a098c100d7 code refactor 2024-07-28 18:01:27 +08:00
MaysWind 8fb209440d update comment 2024-07-28 17:36:51 +08:00
MaysWind d05736d0eb support minio as object storage 2024-07-28 17:36:42 +08:00
MaysWind c92a9e61b0 code refactor 2024-07-28 16:04:34 +08:00
MaysWind 2e04affb00 supports local file system object storage and use it as the default avatar provider 2024-07-28 16:03:20 +08:00
MaysWind 731b6e8bad update comment 2024-07-27 22:08:08 +08:00
MaysWind 2b63e50837 fix category name not show in transaction edit dialog when category is hidden 2024-07-26 00:50:58 +08:00
MaysWind e22f512f22 show "without tags" in table header 2024-07-26 00:29:42 +08:00
MaysWind 9a83393290 modify style 2024-07-26 00:28:08 +08:00
MaysWind 30ebe49875 allow to filter without tags 2024-07-26 00:22:11 +08:00
MaysWind e15a850cfe allow to filter with all tags 2024-07-26 00:07:59 +08:00
MaysWind 34ebc06a8d aadd transaction tag filter to backend 2024-07-26 00:00:22 +08:00
MaysWind a86428cc4d remove unused code 2024-07-25 22:39:50 +08:00
MaysWind efce4cc04e remove clear button 2024-07-25 01:07:04 +08:00
MaysWind a8484cfcaf remove display password button 2024-07-25 01:03:25 +08:00
MaysWind cc55b98e80 PIN code input supports Home & End keys and does not process f1-f12 keys and alt key 2024-07-25 00:51:19 +08:00
MaysWind 0620194c78 support showing hidden categories in filtering page / dialog 2024-07-25 00:43:21 +08:00
MaysWind a00a67f3d1 set select button disabled when there are no visible items 2024-07-25 00:18:26 +08:00
MaysWind 6b9ad1a1c8 set display transaction tags in transaction list page by default 2024-07-24 23:52:15 +08:00
MaysWind 3d0b993c45 modify style 2024-07-24 23:49:43 +08:00
MaysWind dc74ac0d0b code refactor 2024-07-24 23:48:19 +08:00
MaysWind 7ed923b347 hide sub accounts whose parent account is hidden 2024-07-24 23:36:56 +08:00
MaysWind 1f63aa8cdf show no available accounts when all accounts are hidden 2024-07-24 23:30:53 +08:00
MaysWind 84f7eab95d show no available tags when all tags are hidden 2024-07-24 23:30:33 +08:00
MaysWind 7d6c7f49e5 support showing hidden accounts in filtering page / dialog 2024-07-24 01:16:07 +08:00
MaysWind 021e523d63 support showing hidden tags in filtering page / dialog 2024-07-24 01:16:00 +08:00
MaysWind 266dafa4a9 add comment 2024-07-23 23:50:04 +08:00
MaysWind 579c903398 not allow to add / modify / delete transaction with account whose parent account is hidden 2024-07-23 23:49:53 +08:00
MaysWind 5d2e880bc5 not allow to add / modify / delete transaction with parent account 2024-07-23 23:18:34 +08:00
MaysWind 8085f7cf11 modify style 2024-07-23 01:34:35 +08:00
MaysWind aea4cf7e8b fix the bug that hidden tags don't display in transaction detail page 2024-07-23 01:12:31 +08:00
MaysWind 3d56bfa114 not allow to add transaction with hidden transaction tag 2024-07-23 00:53:31 +08:00
MaysWind a7280bf7ed not allow to add transaction with hidden transaction category 2024-07-23 00:47:19 +08:00
MaysWind 085f9817fc not allow to set hidden account as default account 2024-07-23 00:28:20 +08:00
MaysWind fcca77bca5 auto choose the first non-hidden category when opening transaction create page / dialog 2024-07-23 00:18:44 +08:00
MaysWind 4bf2e94a9d hide categories which are hidden in transaction edit page / dialog 2024-07-23 00:18:28 +08:00
MaysWind 95c2494545 code refactor 2024-07-23 00:18:18 +08:00
MaysWind 7662e0eb02 list sheet, tree view sheet and two column select components support hidden field 2024-07-23 00:17:45 +08:00
MaysWind 9f438dd648 fix the bug that the hidden accounts, categories or tags are not displayed in the filter menu when they are chosen in transaction list page 2024-07-22 00:50:36 +08:00
MaysWind 08a1f3a5f7 code refactor 2024-07-22 00:35:36 +08:00
MaysWind 26d77de67a add transaction tag filter to frontend 2024-07-22 00:34:37 +08:00
MaysWind 4f9ab9db75 code refactor 2024-07-21 23:35:17 +08:00
MaysWind 0b83518921 update comments 2024-07-21 20:56:46 +08:00
MaysWind 0f8de8d699 custom map tile server supports annotation layer 2024-07-21 20:50:49 +08:00
MaysWind aae23c285e map provider supports TianDiTu 2024-07-21 20:00:57 +08:00
MaysWind daf73dc964 code refactor 2024-07-21 18:59:00 +08:00
MaysWind bc0893b518 add comment 2024-07-21 18:37:32 +08:00
MaysWind a87bda09f7 modify the calculation strategy of month total amount 2024-07-21 17:45:42 +08:00
MaysWind aa7652279e remove unused parameter 2024-07-21 17:04:47 +08:00
MaysWind d86dea4081 modify hint 2024-07-16 00:39:49 +08:00
MaysWind 15d476fd40 modify log content prefix 2024-07-16 00:32:32 +08:00
MaysWind 453a9ff61c code refactor 2024-07-16 00:18:43 +08:00
MaysWind 26e9a0ef2a only show chart data type of categorical analysis in statistics setting page 2024-07-15 01:25:33 +08:00
MaysWind 7849b2f05c update url address when changing the settings on the statistics analysis page 2024-07-15 01:13:45 +08:00
MaysWind 2cbcc40ca9 fix the bug that the overview amounts in home page would not update after changing the first day of week 2024-07-14 18:15:41 +08:00
MaysWind 184dad8185 code refactor 2024-07-14 18:14:08 +08:00
MaysWind 46a7cd441f code refactor 2024-07-14 18:07:13 +08:00
MaysWind 55249e07a3 support setting token min refresh interval 2024-07-14 17:47:35 +08:00
MaysWind d4850b4a18 fix the bug that the first day of week setting does not take effect in the transaction list page in desktop version 2024-07-14 16:59:07 +08:00
MaysWind 93819d5894 check whether the setting value is valid and modify the allowed minimum value of settings 2024-07-14 16:15:11 +08:00
MaysWind 4a4cec3d69 modify default token expired time in code 2024-07-14 15:32:28 +08:00
MaysWind 432993121c fix the bug that cannot use multiple sessions to access at the same time after the application lock is enabled 2024-07-14 14:26:08 +08:00
MaysWind 1ce0c62c30 update local expense / income amount color settings 2024-07-14 11:09:09 +08:00
MaysWind b1343ba92a support setting expense / income amount color 2024-07-14 01:00:14 +08:00
MaysWind 84a96d80b7 support showing transaction tag in transaction list page 2024-07-10 01:08:21 +08:00
MaysWind 4b249a0ebb code refactor 2024-07-09 22:40:53 +08:00
MaysWind 58de308f30 modify variable name 2024-07-09 22:23:26 +08:00
MaysWind f151eb6197 add command transaction-tag-index-fix-transaction-time to fix transaction tag index which does not have transaction time 2024-07-09 00:53:22 +08:00
MaysWind 9eaac329b9 modify log 2024-07-09 00:25:35 +08:00
MaysWind a33123022f command transaction-check supports checking whether transaction tag index has transaction time 2024-07-09 00:25:13 +08:00
MaysWind 3eac9af403 code refactor 2024-07-09 00:06:18 +08:00
MaysWind a371058096 fix the bug that the transaction time was not correctly saved into the transaction tag index table 2024-07-08 23:54:23 +08:00
MaysWind ee003160e5 update comment 2024-07-08 01:16:24 +08:00
MaysWind 847349dcbd support using duplicate checker to prevent duplicate submissions for new transaction record 2024-07-08 01:10:04 +08:00
MaysWind a2d6aff28b show warning log when secret_key is not set 2024-07-07 21:27:41 +08:00
MaysWind cc3e1f2978 code refactor 2024-07-07 17:28:25 +08:00
MaysWind dc9bf1a1d8 modify date format 2024-07-07 17:22:44 +08:00
MaysWind 32af32d02e code refactor 2024-07-07 17:07:13 +08:00
MaysWind d91c99c177 upgrade third party dependencies 2024-07-07 17:07:08 +08:00
MaysWind d0e8419b2e use the account / transaction category filter of the statistics page when navigating from the statistics page to the transaction list page 2024-07-07 14:37:20 +08:00
MaysWind dad3f1041e fix the bug that the filter does not take effect when navigating from the account or statistics page to the transaction list page 2024-07-07 14:08:53 +08:00
MaysWind 7b70b2db29 only show categories with specified type in category filter dialog / page 2024-07-07 13:37:56 +08:00
MaysWind e5a04596e1 modify style 2024-07-07 11:35:31 +08:00
MaysWind 297f8b9987 do not reload transaction list when filter is not changed actually 2024-07-07 11:18:43 +08:00
MaysWind 9eddab3dd8 modify style 2024-07-07 10:38:47 +08:00
MaysWind ec97d2df91 remove unused code 2024-07-07 10:18:53 +08:00
MaysWind 3dd39defc1 support filter multiple accounts and categories in transaction list page 2024-07-07 01:33:22 +08:00
MaysWind c0cc9b5247 code refactor 2024-07-06 21:10:52 +08:00
MaysWind dc13afc071 code refactor 2024-07-06 21:02:49 +08:00
MaysWind 628bd48f73 code refactor 2024-07-05 01:25:51 +08:00
MaysWind c827675e14 code refactor 2024-07-04 23:48:41 +08:00
MaysWind 09fb474921 code refactor 2024-07-04 23:41:02 +08:00
MaysWind 7e1338e081 fix the bug that there are duplicate transaction in transaction list response when filtering multiple accounts 2024-07-04 00:55:41 +08:00
MaysWind 7a5d7337cd code refactor 2024-07-04 00:15:32 +08:00
MaysWind b80041433c transaction list api supports filtering by multiple account / category 2024-07-03 00:21:56 +08:00
MaysWind dd32ab83cb modify style 2024-07-02 22:34:48 +08:00
MaysWind c8db448dfc fix the bug that the page cannot be loaded when clicking the date which is already chosen 2024-07-02 01:20:38 +08:00
MaysWind 51edca66d1 add more currency display type 2024-07-02 01:10:34 +08:00
MaysWind bb5529098f add comment 2024-07-02 01:07:14 +08:00
MaysWind d5a54dd1fb code refactor 2024-07-02 01:03:40 +08:00
MaysWind 329119fc3b modify text 2024-07-02 00:46:22 +08:00
MaysWind e43cf26bb5 code refactor 2024-07-01 23:27:34 +08:00
MaysWind 93bc3bf94b code refactor 2024-07-01 23:03:55 +08:00
MaysWind 675b5f039a remove unused code 2024-07-01 22:57:07 +08:00
MaysWind b3e4e39b49 code refactor 2024-07-01 01:00:55 +08:00
MaysWind 4dc072f56d code refactor 2024-07-01 00:53:29 +08:00
MaysWind b5d72c89f2 support filtering transaction amount 2024-07-01 00:48:00 +08:00
MaysWind d2b3900ed4 code refactor 2024-06-30 21:18:21 +08:00
MaysWind 4e44553c07 modify style 2024-06-30 18:13:36 +08:00
MaysWind ec6b5fb155 move type filter to more filter popover menu 2024-06-30 17:51:00 +08:00
MaysWind 16e53feaf4 fix the bug that cannot view the location on the map of an existing transaction in desktop page 2024-06-30 16:43:58 +08:00
MaysWind 182bdb34cd code refactor 2024-06-30 16:25:39 +08:00
MaysWind 59d03b54d7 move currency display type to user settings 2024-06-30 16:25:32 +08:00
MaysWind 445969c449 remove unused code 2024-06-30 12:05:41 +08:00
MaysWind 29214a3bf3 remove unused code 2024-06-30 01:52:52 +08:00
MaysWind d979dc3535 code refactor 2024-06-30 01:52:20 +08:00
MaysWind 399413a270 support setting decimal separator and digit grouping symbol 2024-06-30 01:48:36 +08:00
MaysWind d9c8142c51 code refactor 2024-06-29 17:12:02 +08:00
MaysWind c951285049 modify http response code 2024-06-29 16:15:14 +08:00
MaysWind 0e372969b3 fix wrong text 2024-06-29 15:17:36 +08:00
MaysWind 2d51f7b2be show provider of exchange rates data and map in about page 2024-06-29 14:09:47 +08:00
MaysWind 02a5dcf9ba fix the bug that the transaction view dialog does not show no location 2024-06-29 11:51:39 +08:00
MaysWind 9a70cfe24c modify style 2024-06-29 11:46:12 +08:00
MaysWind 023d875a00 modify style 2024-06-29 11:13:25 +08:00
MaysWind 640f74c612 add keyword parameters to the URL when searching for transaction descriptions in the desktop transaction list 2024-06-29 10:43:20 +08:00
MaysWind 768b005200 only show the types of categorical analysis 2024-06-28 22:24:08 +08:00
MaysWind fb315127f9 fix the bug that the frontend would not display any secondary transaction categories after modifying the primary transaction category 2024-06-24 00:51:57 +08:00
MaysWind 756e6cac5a update currency name 2024-06-24 00:42:10 +08:00
MaysWind daae5b68cd hide trend analysis settings in statistics settings page 2024-06-24 00:23:54 +08:00
MaysWind 0e391bee50 support changing primary category for transaction category 2024-06-24 00:21:47 +08:00
MaysWind 9627e65d6d display items in specified sorting type in tooltip for trend analysis chart 2024-06-23 22:29:47 +08:00
MaysWind 830974500b modify style 2024-06-23 21:56:54 +08:00
MaysWind 0438964e17 add seconds to time column in exported data 2024-06-17 00:30:15 +08:00
MaysWind 226d44651f code refactor 2024-06-17 00:22:32 +08:00
MaysWind db71ac5279 remove unused code 2024-06-17 00:20:04 +08:00
MaysWind 7e2a0b1483 set default trends date range type to this year 2024-06-10 23:48:56 +08:00
MaysWind a219444953 trends analysis supports total expense / total income / total balance 2024-06-10 23:42:41 +08:00
MaysWind 5fec41055e keep the state of selected legend 2024-06-10 23:26:04 +08:00
MaysWind 5107e451d8 hide trends chart when no transaction data 2024-06-10 22:35:45 +08:00
MaysWind 3b34cdbda2 remove unused code 2024-06-10 22:26:57 +08:00
MaysWind 35fcd32a96 show total amount on tooltip for trend chart 2024-06-10 22:19:31 +08:00
MaysWind cf325b4267 auto change y-axis width based on label width 2024-06-10 22:02:30 +08:00
MaysWind 860ef610b7 modify style 2024-06-10 22:00:55 +08:00
MaysWind 5c5e6a60a7 update README.md 2024-06-09 23:13:26 +08:00
MaysWind 435c38fb27 fix the bug that the build.bat scripts would not immediately stop when a part of the build fails 2024-06-09 23:13:16 +08:00
MaysWind d640b7a5f5 modify style 2024-06-09 23:13:07 +08:00
MaysWind ae25b4eb4e modify style 2024-06-09 23:12:52 +08:00
MaysWind 315a686fed upgrade third party dependencies 2024-06-09 23:12:36 +08:00
MaysWind 4dd79b07d9 fix the bug that cannot set the date range automatically when navigate to transaction list page by clicking category in statistics page in mobile version 2024-06-09 02:35:59 +08:00
MaysWind e88d803232 add trends analysis chart 2024-06-09 02:31:13 +08:00
MaysWind 1489854444 Fix the bug that no transaction would display when all date range in categorical analysis is selected 2024-06-09 01:30:41 +08:00
MaysWind c5f9e276b4 modify style 2024-06-09 00:57:02 +08:00
MaysWind b2a8b359d1 code refactor 2024-06-08 23:05:55 +08:00
MaysWind 72f6a9e9a3 use the preset name if custom date range matches a preset item 2024-06-03 00:47:39 +08:00
MaysWind 809172bf34 add date filter for trend analysis 2024-06-03 00:30:25 +08:00
MaysWind c34887240e trend analysis supports data from all dates 2024-06-02 22:06:52 +08:00
MaysWind f041e7cb7d add chart date type settings for trend analysis 2024-05-27 01:36:46 +08:00
MaysWind 5eca777891 add chart type and chart data type settings for trend analysis 2024-05-26 23:58:26 +08:00
MaysWind a9e3b79eb1 fix the bug that the selected date range in date selection dialog would not change to current set range after opening the date selection dialog in desktop version 2024-05-26 22:57:57 +08:00
MaysWind 0a376217c6 upgrade third party dependencies 2024-05-26 22:00:01 +08:00
MaysWind 687d062bfc modify style 2024-05-26 20:25:18 +08:00
MaysWind e123498895 code refactor 2024-05-26 19:49:04 +08:00
MaysWind a917d16c26 code refactor 2024-05-26 19:48:40 +08:00
MaysWind 0884af038d add trend analysis api 2024-05-20 00:01:40 +08:00
MaysWind 72619f3dad upgrade golang to 1.21.9, node.js to 18.20.2 2024-05-19 17:02:51 +08:00
MaysWind cf120dbcbf fix the bug that cannot load more transaction after opening and clicking cancel button custom time range dialog 2024-05-19 16:57:23 +08:00
MaysWind 9906f1b1a7 fix the bug that the "Date" is highlighted after custom date sheet opened even the date range is not set in mobile transaction list page 2024-05-01 16:40:29 +08:00
MaysWind ea32bfa5fc support setting timezone type for the time range of statistical data 2024-04-13 23:31:24 +08:00
MaysWind 14f6de8af1 remove unused code 2024-04-06 23:58:22 +08:00
MaysWind 176d5a15c5 show "-dev" after the version number when the build version is not a release version 2024-04-06 17:05:04 +08:00
MaysWind cfc7a5bd49 modify style 2024-04-06 16:37:48 +08:00
MaysWind 4b68ccf678 update text content 2024-04-06 16:33:08 +08:00
MaysWind cb57b216d0 fix the bug that the sheet would display blank after clicking now button and switching from date mode to time mode in date time selection sheet 2024-04-05 23:45:33 +08:00
MaysWind 222951139c swap the location of now and switch mode button 2024-04-05 23:33:52 +08:00
MaysWind 821c7bfbd3 modify style 2024-04-05 23:24:06 +08:00
MaysWind 722c1d7917 fix missing go.sum entry 2024-04-05 23:06:42 +08:00
MaysWind 4d065bc724 fix the bug that the status of email changes to unverified after clicking reset button in the user basic setting page 2024-04-05 22:57:48 +08:00
MaysWind 2a2cb3acc9 modify style 2024-04-05 22:45:06 +08:00
MaysWind 4a16b82700 upgrade Materio to 2.2.1 2024-04-05 00:19:48 +08:00
MaysWind ea97f8cf7a update third party dependencies 2024-04-04 21:16:40 +08:00
MaysWind 28f113d992 code refactor 2024-04-04 20:44:37 +08:00
MaysWind 46caf46ef7 sort languages by language code 2024-03-30 15:41:34 +08:00
MaysWind 185758b638 use current platform to build frontend assets 2024-03-25 00:30:56 +08:00
MaysWind 1e047aed80 code refactor 2024-03-24 17:41:17 +08:00
MaysWind 8ef7676b4f modify text 2024-03-24 15:41:52 +08:00
MaysWind b2fc24d5ae add geographic location to exported file 2024-03-24 15:38:09 +08:00
MaysWind 065cd9dff8 modify text 2024-03-24 15:14:13 +08:00
MaysWind 532c762553 code refactor 2024-03-24 13:54:13 +08:00
MaysWind 4857055eaf modify text 2024-03-24 13:44:57 +08:00
MaysWind 604379bf85 modify style 2024-03-24 13:35:29 +08:00
MaysWind 3d0f793cf6 code refactor 2024-03-24 01:41:42 +08:00
MaysWind 9f8634ac11 add date navigation button in desktop version transaction list page 2024-03-24 01:40:55 +08:00
MaysWind 6ab70b97b4 move all date to the top of navigation bar 2024-03-24 01:16:07 +08:00
MaysWind 8c164ec833 modify style 2024-03-24 01:09:42 +08:00
MaysWind 09d47459b6 add toggle calendar and time picker button 2024-03-10 20:48:20 +08:00
MaysWind 2916106f27 auto set destination amount by source amount when destination amount is zero 2024-03-10 19:55:29 +08:00
MaysWind 6b4292596a add date navigation button in mobile version transaction list page 2024-03-10 19:05:55 +08:00
MaysWind 78aebb7d6c show the transaction time in default timezone on tooltip in desktop version transaction list page when the transaction time zone is not the default time zone 2024-03-10 13:45:21 +08:00
MaysWind 771bb35e9f show the transaction time in default timezone by clicking the transaction time in mobile version transaction view page when the transaction time zone is not the default time zone 2024-03-10 13:45:08 +08:00
MaysWind 453ed4227d modify style 2024-03-10 13:44:41 +08:00
MaysWind 88a284a21b use default cursor when input is readonly 2024-03-10 11:52:50 +08:00
MaysWind 9488b85705 fix the bug that the time picker for mobile device displays and sets incorrectly when the default time zone is not the browser time zone 2024-03-10 02:34:28 +08:00
MaysWind 8be3fd7ed4 remove unused import 2024-03-10 01:15:10 +08:00
MaysWind 8fdc0019ea fix the bug that the text size of action menu title label does not follow the app text size change 2024-03-09 23:02:25 +08:00
MaysWind b21dd73ff2 fix default time is wrong in add transaction dialog when default time zone is not the browser time zone in desktop version 2024-03-09 22:49:07 +08:00
MaysWind 24432701dd modify style 2024-03-09 22:17:28 +08:00
MaysWind 802cdabd75 the order of year and month in the date picker is based on the order in long date format set by the user 2024-03-09 21:51:23 +08:00
MaysWind 8c83af1543 add swap account and/or amount menu in transaction edit page 2024-03-04 00:28:20 +08:00
MaysWind deb465377e show exchange rate in transaction edit page when the currencies of source account and destination account are different 2024-03-04 00:09:21 +08:00
MaysWind bed2aef7f8 use time picker in datetime selection sheet for mobile device 2024-03-03 23:01:27 +08:00
MaysWind 57b2b2492f fix cannot build frontend on arm/v6 or arm/v7 platform 2024-03-03 18:52:14 +08:00
MaysWind 87cdaee547 update base image and go/node version 2024-03-03 16:58:05 +08:00
MaysWind a8b0ef2483 update third party dependencies 2024-03-03 14:06:36 +08:00
MaysWind 63e976e5cb update third party dependencies 2024-03-03 13:33:28 +08:00
MaysWind f73830d37b bump year to 2024 2024-03-03 13:19:48 +08:00
MaysWind 6511c4e810 update exchange rates data api of National Bank Of Poland 2024-03-03 12:12:11 +08:00
MaysWind 008c58f52b use custom user-agent to request exchange rates data 2024-03-03 11:51:58 +08:00
MaysWind fa4a17f47b support setting proxy to request exchange rates or map data 2024-03-03 11:46:30 +08:00
MaysWind 3fc2a763b4 set navigation button disabled when reloading 2023-12-04 00:23:53 +08:00
MaysWind 1b2726a55c update third party dependencies 2023-12-03 20:47:59 +08:00
MaysWind 229fa27b73 modify style 2023-11-19 22:12:09 +08:00
f97 072f44245d typo: third-party-dependencies.json 2023-11-01 21:55:54 +08:00
MaysWind dc837c430f support export to tsv file 2023-10-29 21:05:17 +08:00
MaysWind 429e270a9e modify x-small chip style 2023-10-29 16:33:55 +08:00
MaysWind 985cefc297 update third party dependencies 2023-10-29 16:28:36 +08:00
MaysWind 285fc0ced3 map provider supports CartoDB 2023-10-29 16:18:52 +08:00
MaysWind 777e14b6d4 show error when request file name extension is invalid when using map image proxy 2023-10-29 14:54:58 +08:00
MaysWind d18e8211ca show error when specified map provider is not current provider when using map image proxy 2023-10-29 14:47:18 +08:00
MaysWind 2984980a54 code refactor 2023-10-29 14:39:44 +08:00
MaysWind 2302f31f18 set min zoom level to leaflet control 2023-10-29 14:16:47 +08:00
MaysWind f673677c2a support custom map tile server url 2023-10-29 14:09:51 +08:00
MaysWind acc9bf77fb update third party dependencies 2023-10-16 00:29:51 +08:00
MaysWind 678769da0e code refactor 2023-10-16 00:20:00 +08:00
MaysWind 2389208c95 optimize service worker config 2023-10-09 21:48:41 +08:00
MaysWind 28a1080ccc fix redundant code 2023-09-26 01:00:54 +08:00
MaysWind 432d3fd3c3 add missing files when building package 2023-09-26 00:57:56 +08:00
MaysWind 8fc73b866b fix the issue of checking the result code 2023-09-26 00:52:39 +08:00
MaysWind dbc62aac93 fix unit test failure 2023-09-25 23:37:16 +08:00
MaysWind 79802a46fd add build script for windows 2023-09-25 01:28:38 +08:00
MaysWind 3d8ecb42c1 change disabled column nullable 2023-09-24 22:34:00 +08:00
MaysWind bcaf554ae4 navigate to the home page immediately after sign up successfully 2023-09-18 01:41:35 +08:00
MaysWind 3ac4a921e0 update vue-datepicker to 6.1.0 2023-09-18 01:27:54 +08:00
MaysWind 128f86b643 remove redundant code 2023-09-18 01:27:15 +08:00
MaysWind 828fc690b6 bump version to 0.5.0 2023-09-18 01:11:04 +08:00
MaysWind 55d06640b5 auto hide forget password sheet after email has been resent 2023-09-17 19:37:26 +08:00
MaysWind 3874a8da21 clear email when open forget password sheet 2023-09-17 19:36:36 +08:00
MaysWind 6a3ecd5b09 add cancel button to forget password sheet 2023-09-17 19:34:20 +08:00
MaysWind afcd4f7262 support resending verify mail on mobile device 2023-09-17 19:32:12 +08:00
MaysWind b0ae731fa6 update golang to 1.20.8 2023-09-17 18:12:55 +08:00
MaysWind 0b678fe69a code refactor 2023-09-17 18:07:09 +08:00
MaysWind 4cecc78a74 don't show verify email has sent to when there are no valid verify tokens 2023-09-17 18:01:10 +08:00
MaysWind 92273d2fc6 add skip_tls_verify option for exchange rates 2023-09-17 17:24:41 +08:00
MaysWind 04ec749c3c fix the bug that old uuid may be generated sometimes 2023-09-16 23:53:45 +08:00
MaysWind 165377816c return error when uuid is not enough 2023-09-16 22:45:00 +08:00
MaysWind 729904e1c3 remove unused code 2023-09-15 21:54:25 +08:00
MaysWind 6bc4fa0a82 update vuetify to 3.3.16 2023-09-15 21:41:28 +08:00
MaysWind f12403672b update README 2023-09-10 23:30:44 +08:00
MaysWind 97daff8d4f update mobile screenshot img url 2023-09-10 22:12:36 +08:00
MaysWind a32451fd7f hide resend verify email button when server disables verify email 2023-09-10 17:29:10 +08:00
MaysWind ca14770971 return error entity when verify email is not enabled 2023-09-10 17:25:23 +08:00
MaysWind 35ac0695c7 don't show send mail tips when force verify email is enabled but verify email is not enabled 2023-09-10 17:15:56 +08:00
MaysWind 589b614a53 remove old email verify token before send new verify email when email changed 2023-09-10 17:00:19 +08:00
MaysWind 64ea3e05d8 aysnc send email 2023-09-10 16:49:24 +08:00
MaysWind 5d0e115438 auto send verify email when user email has been changed 2023-09-10 16:40:48 +08:00
MaysWind 9f35c1eded improve user registration page 2023-09-10 16:21:29 +08:00
MaysWind ff07346fe9 modify style 2023-09-10 00:25:52 +08:00
MaysWind 22fffc2f8c profile page supports resending verify email 2023-09-10 00:25:42 +08:00
MaysWind 205363dd42 change link text when email is verified 2023-09-09 21:33:17 +08:00
MaysWind d2297b882f send verify email after account has been registered 2023-09-09 21:28:17 +08:00
MaysWind 48bf8dbc5b don't create temporary token when smtp is not enabled 2023-09-09 21:25:30 +08:00
MaysWind 9585c760d5 modify style 2023-09-09 16:39:28 +08:00
MaysWind 8c2cd0aa4d modify style 2023-09-09 15:56:21 +08:00
MaysWind 2e680b04c9 supports building for different platforms for gitea actions 2023-09-04 23:33:52 +08:00
MaysWind e2b81f7b57 add email verification 2023-09-04 23:30:33 +08:00
MaysWind c38b277887 disabled user cannot use forget password 2023-09-03 23:35:01 +08:00
MaysWind a1f6304b22 fix log content is wrong when content has % symbol 2023-09-03 23:15:40 +08:00
MaysWind 09d7f56efc modify log formatter 2023-09-03 13:56:19 +08:00
MaysWind 9221f3fc96 add request id to sql query log 2023-09-03 13:54:07 +08:00
MaysWind 6b30a0aebc supports building multiple image by gitea actions 2023-09-02 19:33:48 +08:00
MaysWind 158563f387 code refactor 2023-09-02 00:05:31 +08:00
MaysWind b0fc5752e2 upgrade vuetify to 3.3.15 2023-09-01 23:28:18 +08:00
MaysWind 28903615ed fix the problem that unit test would not run sometimes 2023-08-28 23:06:03 +08:00
MaysWind caa83c0432 modify text 2023-08-28 00:16:16 +08:00
MaysWind 045f4a42db replace the app name in the email with the one configured in the config file 2023-08-28 00:11:42 +08:00
MaysWind 3275bc9cae redirect page to login page after reset password successfully 2023-08-27 23:58:00 +08:00
MaysWind 6d14eaefe1 code refactor 2023-08-27 23:51:26 +08:00
MaysWind 03274725be modify text and field name 2023-08-27 23:28:38 +08:00
MaysWind 0951006063 add updating user email verified state utility 2023-08-27 22:54:34 +08:00
MaysWind 616bfc6a2a not allow send password reset mail when email address is not verified 2023-08-27 22:35:16 +08:00
MaysWind c0bfe429ee the language of password reset email set to client language if user language is not set 2023-08-27 22:29:54 +08:00
MaysWind c1d90485a1 reset password don't set authorization header 2023-08-27 22:21:10 +08:00
MaysWind 6c30527684 fix typo 2023-08-27 21:37:22 +08:00
MaysWind 4ac751f492 add send user password reset email command line utility 2023-08-27 21:37:10 +08:00
MaysWind de7b137257 add send test email utility 2023-08-27 21:26:30 +08:00
MaysWind 0bf689fa8d code refactor 2023-08-27 21:23:03 +08:00
MaysWind f31ef1649f support reset password by email reset link 2023-08-27 21:22:52 +08:00
MaysWind c66bc62c41 code refactor 2023-08-26 23:35:53 +08:00
MaysWind 9ee59215c8 code refactor 2023-08-26 23:14:42 +08:00
MaysWind a991adecaf modify style 2023-08-26 19:36:39 +08:00
MaysWind 601b1d5c89 modify style 2023-08-26 19:10:06 +08:00
MaysWind 8b593883a7 modify style 2023-08-26 18:22:19 +08:00
MaysWind bb549c9a89 modify text 2023-08-26 17:57:42 +08:00
MaysWind 9f2005622a fix color is wrong in dark theme 2023-08-26 17:11:29 +08:00
MaysWind 4238bde13a modify style 2023-08-26 00:21:00 +08:00
MaysWind 2d4ce1aac0 modify style 2023-08-26 00:12:29 +08:00
MaysWind 37269abde2 update go.sum 2023-08-26 00:02:05 +08:00
MaysWind 54038eabd4 upgrade vuetify to 3.3.14 2023-08-26 00:01:12 +08:00
MaysWind 57922e3071 modify style 2023-08-25 23:59:05 +08:00
MaysWind ad36dfd448 auto choose the secondary category of selected primary category when create transaction in transaction list page 2023-08-22 00:57:02 +08:00
MaysWind 6e334d2efb hide the category dropdown menu when click the item 2023-08-22 00:05:36 +08:00
MaysWind d3dc1401fd auto hide the dropdown menu when click the menu item 2023-08-21 23:59:48 +08:00
MaysWind 0f59c9911d modify style 2023-08-21 23:54:36 +08:00
MaysWind 0ebfd2bc76 modify style 2023-08-21 23:51:24 +08:00
MaysWind 734625c1e3 modify style 2023-08-21 23:46:34 +08:00
MaysWind eaaea8a64f modify props and fields name 2023-08-21 23:42:07 +08:00
MaysWind c026ab1777 modify style 2023-08-21 23:41:10 +08:00
MaysWind 2c7193efea don't allow show the transaction detail when the transaction type is modify balance 2023-08-21 23:35:59 +08:00
MaysWind 21f5ef469b always display account name even if the account is hidden 2023-08-21 23:35:21 +08:00
MaysWind e7f9eb6e06 modify style 2023-08-21 23:25:15 +08:00
MaysWind 9db9c382ab modify text 2023-08-21 23:16:12 +08:00
MaysWind b2ba626cde hide the buttons which is not supported 2023-08-21 23:15:45 +08:00
MaysWind 85d93c4f4b auto hide the dropdown menu when click the secondary item 2023-08-21 23:02:46 +08:00
MaysWind 09f6dd8d82 add more log in unit test 2023-08-21 01:41:03 +08:00
MaysWind 2aa6df48c6 remove unused code 2023-08-21 01:16:43 +08:00
MaysWind 2a16260b05 transaction edit dialog supports duplicate transaction, edit transaction and save new transaction 2023-08-21 01:11:01 +08:00
MaysWind c28158b041 remove unused code 2023-08-21 00:00:25 +08:00
MaysWind 661199850c remove unused code 2023-08-20 23:53:41 +08:00
MaysWind 67b69a45cc modify style 2023-08-20 23:33:14 +08:00
MaysWind 579322578b modify style 2023-08-20 22:21:50 +08:00
MaysWind 0de65042b0 transaction edit dialog supports transaction category 2023-08-20 20:31:29 +08:00
MaysWind f9643f8651 modify method name 2023-08-20 19:43:22 +08:00
MaysWind fc9ac4c40e modify method name 2023-08-20 19:41:51 +08:00
MaysWind 84843066f2 fix style 2023-08-20 19:23:13 +08:00
MaysWind af3d5c4654 remove unused code 2023-08-20 17:35:11 +08:00
MaysWind 676f3daf50 modify style 2023-08-20 17:30:47 +08:00
MaysWind 54970015cb update third party dependencies 2023-08-20 17:17:41 +08:00
MaysWind 2b49430530 update golang to 1.20.7, update node.js to 18.17.1, update alpine base image to 3.17.5 2023-08-20 17:01:01 +08:00
MaysWind feea5f3518 modify style 2023-08-20 16:57:00 +08:00
MaysWind 04e580b40f modify style 2023-08-20 01:22:00 +08:00
MaysWind f07a3ba97d code refactor 2023-08-20 01:09:23 +08:00
MaysWind f44e3a81ab modify style 2023-08-20 01:08:26 +08:00
MaysWind 77d3bd019e fix the problem that the thousands separator is missing 2023-08-20 00:57:01 +08:00
MaysWind 72725d5ab4 hide set as base currency when the currency is already the base 2023-08-20 00:54:11 +08:00
MaysWind aa717ed1fe fix the problem that the time zone of the modify balance transaction generated by creating a new account was wrong 2023-08-20 00:48:42 +08:00
MaysWind 292b49ba79 modify style 2023-08-20 00:40:56 +08:00
MaysWind c600eb5d5a modify style 2023-08-20 00:33:57 +08:00
MaysWind 6d2f788fa2 code refactor 2023-08-20 00:30:55 +08:00
MaysWind b8acff3e7c code refactor 2023-08-20 00:30:30 +08:00
MaysWind b8bdb03fc0 transaction edit dialog supports transaction time 2023-08-19 22:41:13 +08:00
MaysWind 7257abefb4 fix AM/PM text in datetime picker is not translated 2023-08-19 22:37:26 +08:00
MaysWind 3095613a76 transaction edit dialog supports transaction tags 2023-08-18 00:44:05 +08:00
MaysWind db12b64b3a modify style 2023-08-18 00:03:49 +08:00
MaysWind a081edde25 modify style 2023-08-17 23:01:52 +08:00
MaysWind 5496c4a10a code refactor 2023-08-16 00:51:12 +08:00
MaysWind c968ded99a transaction edit dialog supports map 2023-08-16 00:51:03 +08:00
MaysWind 4224a833b4 code refactor 2023-08-16 00:04:50 +08:00
MaysWind df470f1a5e modify style 2023-08-15 22:53:09 +08:00
MaysWind ed0100a82c add more transaction edit dialog basis code 2023-08-15 01:05:59 +08:00
MaysWind 0ad72e8334 modify style 2023-08-14 23:13:03 +08:00
MaysWind 50ee5d1f49 hide control buttons when loading 2023-08-14 23:12:35 +08:00
MaysWind 94283a8da2 update unit test 2023-08-14 22:47:54 +08:00
MaysWind 86e9a3e838 add transaction edit dialog basis code 2023-08-14 01:03:43 +08:00
MaysWind d77b9ef7c9 code refactor 2023-08-14 00:55:41 +08:00
MaysWind e29afa3155 code refactor 2023-08-14 00:14:53 +08:00
MaysWind 7376fbe7a1 code refactor 2023-08-13 23:58:28 +08:00
MaysWind 04e98e1c39 code refactor 2023-08-13 23:40:29 +08:00
MaysWind ddca6e7ec9 code refactor 2023-08-13 23:33:07 +08:00
MaysWind 1ac12ac668 remove unused code 2023-08-13 23:03:30 +08:00
MaysWind 399936e3ab code refactor 2023-08-13 22:58:52 +08:00
MaysWind 3d086992dc modify style 2023-08-13 22:52:31 +08:00
MaysWind 62ded1290c code refactor 2023-08-13 22:19:59 +08:00
MaysWind 33cbdfbd13 code refactor 2023-08-13 20:48:32 +08:00
MaysWind f5ce6ed4bc code refactor 2023-08-13 20:32:51 +08:00
MaysWind eb9a2ac2e0 code refactor 2023-08-13 20:13:27 +08:00
MaysWind 8bed529d05 code refactor 2023-08-13 20:06:42 +08:00
MaysWind 41a8b8007a code refactor 2023-08-13 20:01:44 +08:00
MaysWind 141dc843f3 modify method name 2023-08-13 19:52:58 +08:00
MaysWind 902825404e support disable boot log 2023-08-13 18:28:08 +08:00
MaysWind 749eaaab30 add parsing request id command utility 2023-08-13 18:19:09 +08:00
MaysWind 8f5767b992 add unit test 2023-08-13 17:27:25 +08:00
MaysWind 715f0c5853 add unittest 2023-08-13 17:08:36 +08:00
MaysWind a960fd3d56 improve unit test 2023-08-13 16:52:20 +08:00
MaysWind 17b8ac6d0b fix gravatar url is invalid when email contains upper characters 2023-08-13 15:37:48 +08:00
MaysWind 4ac78fe4d1 modify method name 2023-08-13 15:30:20 +08:00
MaysWind 957bcf790f code refactor 2023-08-13 15:18:38 +08:00
MaysWind 66f0b38008 code refactor 2023-08-13 15:03:31 +08:00
MaysWind f6a2246aab code refactor 2023-08-13 14:58:11 +08:00
MaysWind fa3e941069 show login link in sign up page 2023-08-13 14:46:06 +08:00
MaysWind 9bb049f746 modify style 2023-08-13 14:21:53 +08:00
MaysWind c41f2a4d65 modify style 2023-08-13 14:20:40 +08:00
MaysWind 06ff9f2499 add add/edit account dialog 2023-08-13 02:00:14 +08:00
MaysWind f91f9fcc94 modify style 2023-08-13 01:35:11 +08:00
MaysWind 9a626b0d4f modify dialog style 2023-08-13 01:34:40 +08:00
MaysWind ac2adaf4ba modify style 2023-08-12 18:16:53 +08:00
MaysWind 3ab198615b code refactor 2023-08-12 13:05:03 +08:00
MaysWind 7c2831098c clear displayed transactions when changing filter 2023-08-12 00:28:32 +08:00
MaysWind 2454e22ea2 support setting items per page in transaction list page 2023-08-11 23:58:30 +08:00
MaysWind b1fb40ca61 modify style 2023-08-08 01:39:50 +08:00
MaysWind e403e938c3 show category comment in list 2023-08-08 01:17:59 +08:00
MaysWind 952ba1f1ea code refactor 2023-08-07 01:32:11 +08:00
MaysWind a25690c2d3 modify style 2023-08-07 01:31:34 +08:00
MaysWind 6b6b9c61d7 auto complete supports auto selected the first item by enter / tab key 2023-08-07 01:14:47 +08:00
MaysWind fcc5e522a7 modify style 2023-08-07 01:10:07 +08:00
MaysWind c33c0487cf add add/edit transaction category dialog 2023-08-07 01:00:02 +08:00
MaysWind 1753a6c247 manually set default icon color 2023-08-07 00:48:48 +08:00
MaysWind 195f513416 code refactor 2023-08-07 00:38:08 +08:00
MaysWind c6d38bb3d7 code refactor 2023-08-06 23:57:23 +08:00
MaysWind 9b2fba9600 fix the problem that not scroll to selected item in color selection sheet or icon icon selection sheet 2023-08-06 23:35:51 +08:00
MaysWind c88f6501fa don't auto hide sheet when select icon 2023-08-06 23:30:10 +08:00
MaysWind c511346160 add missing component 2023-08-06 20:42:36 +08:00
MaysWind 0b02daac1d modify file name 2023-08-06 18:58:05 +08:00
MaysWind 2390c085e4 modify style 2023-08-06 18:52:55 +08:00
MaysWind 698d94feed code refactor 2023-08-05 23:04:29 +08:00
MaysWind a9a6d39127 update vuetify to 3.3.11 2023-08-05 17:14:59 +08:00
MaysWind 395bd31898 move files 2023-08-05 16:51:34 +08:00
MaysWind 7e24492ce8 add category preset for desktop page 2023-08-04 01:00:19 +08:00
MaysWind 19d3d80013 add category preset for desktop page 2023-08-04 00:56:26 +08:00
MaysWind 8c7875d7ea modify text 2023-08-03 18:21:19 +08:00
MaysWind 110ce0d4c6 modify skeleton style 2023-08-02 00:53:12 +08:00
MaysWind ff8c57fdab add transaction category list page 2023-08-02 00:49:37 +08:00
MaysWind 54ccdc57bf modify field name 2023-08-02 00:24:59 +08:00
MaysWind 2463f06ba1 only show add default categories button when really have no category 2023-08-02 00:14:09 +08:00
MaysWind 88d5b1f98f set drag handle disabled when loading or updating 2023-08-02 00:01:12 +08:00
MaysWind 43522e9c81 add all date type range in transaction list page 2023-08-01 22:33:12 +08:00
MaysWind 6e798f739f support click in trend chart in overview page 2023-08-01 22:15:15 +08:00
MaysWind 5b5f1280af fix the problem that the transaction date not display sometimes in transaction list page 2023-08-01 21:47:26 +08:00
MaysWind 2188e8dd78 fix wrong timeout 2023-08-01 09:20:02 +08:00
MaysWind 8659e9ea37 add asset summary card in home page, add 6 more months in trend card 2023-08-01 09:19:33 +08:00
MaysWind 4cff481d61 fix the problem that the monthly total income/expense amount sometimes is wrong 2023-07-31 10:05:30 +08:00
MaysWind 6da910d8fb modify style 2023-07-31 01:22:35 +08:00
MaysWind cc08ad46e3 modify overview page loading style 2023-07-31 01:21:47 +08:00
MaysWind 5b52c1adbb show no data in trend card when there are really no data in recent 6 months 2023-07-30 23:57:47 +08:00
MaysWind a20958a89b adjust display order of expense and income 2023-07-30 23:30:05 +08:00
MaysWind dea36d4b80 add trend in income and expense card in overview page 2023-07-30 23:26:10 +08:00
MaysWind 6cb7e4caf7 modify style 2023-07-30 22:59:03 +08:00
MaysWind 6e41668b25 add more runtime caching pattern 2023-07-30 00:43:04 +08:00
MaysWind d3e1acddc5 code refactor, modify style 2023-07-30 00:26:32 +08:00
MaysWind a9c511eb2e fix the problem that cannot search the keywords which contains & symbol in the transaction list page 2023-07-29 23:02:34 +08:00
MaysWind ffef33a9fc code refactor, set category menu disabled when type is modify balance, modify style 2023-07-29 17:26:50 +08:00
MaysWind 982917ddbb set export data button disabled if no data can be exported 2023-07-29 15:39:50 +08:00
MaysWind 07406a50bb show account icon in user basic setting tab, always show default account name even if the account is hidden, set the submit button disabled when nothing has been changed 2023-07-29 15:35:55 +08:00
MaysWind 78f325e127 code refactor 2023-07-29 15:13:37 +08:00
MaysWind e1bb97a1db improve runtime caching pattern 2023-07-27 00:01:17 +08:00
MaysWind 831952806d hide hidden category in transaction list page 2023-07-26 23:37:43 +08:00
MaysWind 4c57b7a009 fix the border not show in some device 2023-07-26 23:33:01 +08:00
MaysWind 683188f67a code refactor 2023-07-24 23:36:04 +08:00
MaysWind 848e5271ab code refactor 2023-07-24 02:41:00 +08:00
MaysWind 7ca5614c44 remove unused code 2023-07-24 02:40:20 +08:00
MaysWind 70fc781a03 add transaction list page for desktop 2023-07-24 02:36:59 +08:00
MaysWind aafdbab781 fix the problem that the time zone setting did not take effect immediately 2023-07-24 02:23:19 +08:00
MaysWind 5dd0f7ea10 only add date time params to transaction list url when date range type is set to custom in transaction statistics page 2023-07-24 00:22:30 +08:00
MaysWind 9393d9105c add persistent props to date range selection dialog 2023-07-24 00:20:43 +08:00
MaysWind 2c3856be3c change url when switching tab in user/app settings page 2023-07-24 00:20:01 +08:00
MaysWind dc746a51a5 add set as baseline button to exchange rates page for desktop 2023-07-24 00:16:43 +08:00
MaysWind 0f2c9354f0 modify style and hide operation buttons when cursor not hovered on 2023-07-24 00:16:09 +08:00
MaysWind af00032ee9 check whether user is logined when entering every page 2023-07-23 12:00:18 +08:00
MaysWind 5d7e685dc4 use icon to replace symbol character 2023-07-23 01:32:55 +08:00
MaysWind bfcb79c02b modify style 2023-07-22 23:49:55 +08:00
MaysWind ebee6273b0 code refactor 2023-07-22 23:17:13 +08:00
MaysWind b45900e5bc modify text 2023-07-22 23:06:00 +08:00
MaysWind 9f0657683a modify style 2023-07-22 23:04:08 +08:00
MaysWind 35b8d8ca25 modify style 2023-07-21 00:47:13 +08:00
MaysWind 8f7095ce19 transaction tag list page supports dragging to change display order 2023-07-20 23:52:33 +08:00
MaysWind d9c8dd20e9 modify style 2023-07-20 23:29:26 +08:00
MaysWind 8dcc462a30 modify style 2023-07-20 23:16:00 +08:00
MaysWind b561948030 code refactor 2023-07-20 01:37:45 +08:00
MaysWind 2cbd8684cf account list page supports dragging to change display order 2023-07-19 02:32:10 +08:00
MaysWind 107c9fce94 fix default icon color 2023-07-19 00:24:00 +08:00
MaysWind 4c18b3e059 optimize vite build config 2023-07-18 23:52:49 +08:00
MaysWind 9960ec4d58 modify style 2023-07-18 23:15:39 +08:00
MaysWind 2a09e048e4 code refactor 2023-07-18 00:51:47 +08:00
MaysWind 9711f3ba72 modify style 2023-07-18 00:48:54 +08:00
MaysWind 0622d2b81b modify style 2023-07-18 00:46:37 +08:00
MaysWind a372d1fb60 code refactor 2023-07-18 00:41:26 +08:00
MaysWind 99e55e730e code refactor 2023-07-18 00:29:41 +08:00
MaysWind 57d1e915e6 remove unused function reference 2023-07-18 00:18:02 +08:00
MaysWind 96ad6228bd code refactor 2023-07-18 00:17:38 +08:00
MaysWind 678f9fbe87 fix wrong text 2023-07-17 23:14:35 +08:00
MaysWind 714933df56 code refactor 2023-07-17 23:13:54 +08:00
MaysWind f0ef9ad51e remove unused code 2023-07-17 23:05:48 +08:00
MaysWind 0255213934 code refactor 2023-07-17 23:02:51 +08:00
MaysWind 0ad92a999c modify style 2023-07-17 00:35:27 +08:00
MaysWind b06456d573 modify style 2023-07-17 00:13:57 +08:00
MaysWind 6f1fc2c9b4 modify field name 2023-07-17 00:00:12 +08:00
MaysWind 44a1982d87 code refactor 2023-07-16 23:57:18 +08:00
MaysWind 6b0cf5aa96 fix missing plus symbol issue when there are unsupported currencies of sub accounts 2023-07-16 23:56:56 +08:00
MaysWind 2cfac7a772 modify style 2023-07-16 23:48:04 +08:00
MaysWind 99aaf35e0b add account list page for desktop 2023-07-16 23:35:50 +08:00
MaysWind 942ed1fb55 code refactor 2023-07-16 23:12:44 +08:00
MaysWind 25f83a98e3 code refactor 2023-07-16 23:05:11 +08:00
MaysWind ed4040f2ec code refactor 2023-07-16 22:55:02 +08:00
MaysWind 41034de676 modify style 2023-07-16 18:35:30 +08:00
MaysWind 2db0f1a6c8 code refactor 2023-07-16 16:36:47 +08:00
MaysWind 6b06cc7ef5 desktop pie chart supports clicking the chart to scroll to the specified legend 2023-07-16 16:23:56 +08:00
MaysWind 2bf26212af modify transaction statistics page style 2023-07-16 15:28:43 +08:00
MaysWind 9d273c172d modify exchange rates page style 2023-07-16 15:19:44 +08:00
MaysWind 6ae3bc82bb modify style 2023-07-16 14:03:14 +08:00
MaysWind c782002274 modify style 2023-07-16 13:16:43 +08:00
MaysWind cd0906d041 update third party dependencies 2023-07-15 23:45:43 +08:00
MaysWind f936ecca33 manually get backend dependencies before lint checking 2023-07-15 23:44:48 +08:00
MaysWind 8794e3bc53 set confirm and more button disabled when there are no available items 2023-07-15 23:43:44 +08:00
MaysWind 4503e2a222 add necessary vuetify component and remove unnecessary vuetify component 2023-07-15 23:43:06 +08:00
MaysWind 015725f88c support setting account/transaction category filter for statistics page 2023-07-15 16:54:56 +08:00
MaysWind 39451f0e37 code refactor 2023-07-15 16:21:30 +08:00
MaysWind 2bb0b7faa5 modify text color 2023-07-15 16:07:41 +08:00
MaysWind 8db7c3769a remove blank line 2023-07-15 16:07:25 +08:00
MaysWind d4e32de882 item icon supports custom class 2023-07-15 01:38:34 +08:00
MaysWind db75dea9ee fix typo 2023-07-15 00:50:29 +08:00
MaysWind 489bba9c4b modify class name 2023-07-15 00:30:42 +08:00
MaysWind 4accc49d60 reload sessions after password changed 2023-07-15 00:24:47 +08:00
MaysWind 5a47cd5216 modify style 2023-07-14 23:50:59 +08:00
MaysWind 439608cf27 remove unused configuration 2023-07-14 22:46:00 +08:00
MaysWind f9df7a1b5a modify style 2023-07-11 01:21:45 +08:00
MaysWind 78c801994a fix issue after packing js file 2023-07-11 01:16:45 +08:00
MaysWind 882fd68cf9 add some transaction statistics settings 2023-07-11 01:01:11 +08:00
MaysWind 3433b73edf code refactor 2023-07-11 01:00:27 +08:00
MaysWind a235d6a8cd code refactor 2023-07-11 00:35:21 +08:00
MaysWind d76e88af21 code refactor 2023-07-11 00:30:57 +08:00
MaysWind 3f6c6c443a code refactor 2023-07-11 00:26:27 +08:00
MaysWind 13c6d406cf only navigate to transaction list page when click pie chart label in desktop version 2023-07-11 00:15:47 +08:00
MaysWind 19fea4e761 code refactor 2023-07-10 23:26:16 +08:00
MaysWind c84c96dcd1 update service worker config 2023-07-10 22:58:46 +08:00
MaysWind cdd8ccc2d4 modify image path 2023-07-10 22:46:52 +08:00
MaysWind 09210d5d40 remove switch to mobile/desktop version in unlock page 2023-07-10 00:38:22 +08:00
MaysWind 8f44a26037 code refactor 2023-07-10 00:14:33 +08:00
MaysWind 5e986b2d04 add transaction statistics page 2023-07-10 00:04:20 +08:00
MaysWind 298c0922cb fix account icon issue in transaction statistics data page 2023-07-09 21:21:19 +08:00
MaysWind dc127ea6a3 code refactor 2023-07-09 20:43:08 +08:00
MaysWind 522ed94c32 user settings and app settings page supports showing specified tab by query parameter 2023-07-09 16:35:12 +08:00
MaysWind 6edf66a599 code refactor 2023-07-09 14:22:47 +08:00
MaysWind 475ec24528 desktop page supports service worker 2023-07-09 13:20:24 +08:00
MaysWind b555af0df7 add lock application menu 2023-07-09 13:13:35 +08:00
MaysWind f90430e544 modify style 2023-07-09 12:45:40 +08:00
MaysWind 4ccb75818c add switch to mobile/desktop device in login/unlock page 2023-07-09 12:01:29 +08:00
MaysWind c5c9ed24c3 code refactor 2023-07-09 11:12:39 +08:00
MaysWind 386aa0adc1 code refactor 2023-07-09 01:07:10 +08:00
MaysWind 0b26b75699 modify style 2023-07-09 01:04:24 +08:00
MaysWind d013f67c70 itemicon supports hidden status 2023-07-09 00:43:42 +08:00
MaysWind dc7c0e61fd code refactor 2023-07-09 00:37:51 +08:00
MaysWind 89bd041d29 add transaction tag list page 2023-07-09 00:37:19 +08:00
MaysWind ac730d6086 remove number input stepper 2023-07-08 22:56:16 +08:00
MaysWind 427eaed544 code refactor 2023-07-08 20:31:41 +08:00
MaysWind 00f783c0b6 modify style 2023-07-08 20:28:15 +08:00
MaysWind 8e1e53d55e code refactor 2023-07-08 20:07:09 +08:00
MaysWind 5c9a5c13b8 modify style 2023-07-08 19:42:33 +08:00
MaysWind c1f03a8e75 modify style 2023-07-08 17:51:15 +08:00
MaysWind 1affa83620 code refactor 2023-07-08 16:42:35 +08:00
MaysWind 2aa8180113 code refactor 2023-07-08 16:16:28 +08:00
MaysWind e6001d83a4 modify style 2023-07-08 16:16:04 +08:00
MaysWind 8550c8fde9 modify style 2023-07-08 12:25:18 +08:00
MaysWind 48d9a09307 add sign up page 2023-07-08 02:59:50 +08:00
MaysWind 9d0b874488 code refactor 2023-07-07 23:48:32 +08:00
MaysWind 062a13b2c2 modify style 2023-07-07 22:53:22 +08:00
MaysWind 937f8723ed add background img for dark theme 2023-07-06 01:07:57 +08:00
MaysWind 0c5cabbd79 modify color 2023-07-06 00:56:10 +08:00
MaysWind 95d8f710d8 exclude unnecessary file from precaching files, add some files to runtime caching 2023-07-06 00:38:28 +08:00
MaysWind bb9b8b34e5 modify img path 2023-07-06 00:04:54 +08:00
MaysWind 54c1164bd7 add illustrations to login/unlock page 2023-07-05 01:27:05 +08:00
MaysWind 89c1158d95 modify style 2023-07-04 23:06:52 +08:00
MaysWind 9cae189941 modify text tip 2023-07-03 23:04:44 +08:00
MaysWind 53a31cd4c4 use variables to replace secrets 2023-07-03 23:02:17 +08:00
MaysWind d72a615481 code refactor 2023-07-03 23:01:31 +08:00
MaysWind 8dcffa80a8 exclude vendor files for desktop page 2023-07-02 23:50:57 +08:00
MaysWind 7cf53acd18 auto focus pin code input when open desktop unlock page 2023-07-02 23:38:15 +08:00
MaysWind 748cf68055 show avatar placeholder when loading user avatar 2023-07-02 23:34:09 +08:00
MaysWind 9cd22bdc06 add application lock setting tab for desktop 2023-07-02 23:22:59 +08:00
MaysWind 5830d4b91c desktop page support icon/startup image 2023-07-02 23:22:15 +08:00
MaysWind b42f7f566b add unlock page for desktop 2023-07-02 23:22:09 +08:00
MaysWind db8223ca98 fix the language not set to system language 2023-07-02 21:44:56 +08:00
MaysWind 9403afc392 code refactor 2023-07-02 21:44:37 +08:00
MaysWind 2b2d3b9517 code refactor 2023-07-02 20:01:59 +08:00
MaysWind 3eebdfcdb3 modify style 2023-07-02 19:31:47 +08:00
MaysWind f7766bc3d4 update Chinese translation 2023-07-02 19:28:53 +08:00
MaysWind f2614abbdd remove unused code 2023-07-02 19:25:30 +08:00
MaysWind 58824ea879 modify style 2023-07-02 19:09:40 +08:00
MaysWind 9ef7a18847 modify login page style 2023-07-02 19:07:55 +08:00
MaysWind 58c0167696 fix issue 2023-07-02 18:36:11 +08:00
MaysWind 9adfd286f9 code refactor 2023-07-02 00:51:26 +08:00
MaysWind 4e8f530fbb code refactor 2023-07-01 23:38:17 +08:00
MaysWind 3e694b0772 update Chinese translation 2023-07-01 23:27:09 +08:00
MaysWind 2fd5b04d4d code refactor 2023-07-01 23:26:31 +08:00
MaysWind 4688b9a9c9 move "Switch to Mobile Version" to "Use on Mobile Device" dialog 2023-07-01 23:16:53 +08:00
MaysWind 153e0035ba reset state after leaving 2fa setting tab 2023-07-01 23:10:49 +08:00
MaysWind 88b6ecc557 add alert component 2023-07-01 22:51:54 +08:00
MaysWind 9df55874a6 show username and avatar in user basic setting tab 2023-07-01 22:51:42 +08:00
MaysWind 87d4ab827a add reset button in user basic setting tab 2023-07-01 22:12:06 +08:00
MaysWind 318166f23a code refactor 2023-07-01 02:26:55 +08:00
MaysWind cc3f712659 remove unused code 2023-07-01 02:22:30 +08:00
MaysWind ee399d8a08 code refactor 2023-07-01 02:19:04 +08:00
MaysWind 96c233d5c5 code refactor 2023-07-01 02:05:36 +08:00
MaysWind 652b3e1954 support set gravatar as user avatar provider 2023-07-01 00:53:59 +08:00
MaysWind a8b76d5537 modify style 2023-07-01 00:15:17 +08:00
MaysWind e041b70cdd modify style 2023-07-01 00:09:15 +08:00
MaysWind 63bf912b3e upgrade vuetify to 3.3.6 2023-06-30 23:43:13 +08:00
MaysWind 0cbe7b1655 code refactor 2023-06-30 23:41:40 +08:00
MaysWind 7bbec29c5b show whether data is updated after click refresh button 2023-06-28 21:44:01 +08:00
MaysWind 7cec7dbac8 sort result in overview response 2023-06-28 21:41:59 +08:00
MaysWind 09a19b5f42 navigate to desktop page when use tablet device 2023-06-28 21:38:21 +08:00
MaysWind 8fe765c097 modify file name 2023-06-25 00:39:00 +08:00
MaysWind b11e8e07c4 set button disabled when required input is not filled 2023-06-25 00:30:48 +08:00
MaysWind f72763306d code refactor 2023-06-25 00:25:43 +08:00
MaysWind e3d1a476e2 fix wrong watching parameter 2023-06-24 23:59:53 +08:00
MaysWind f0bc86d42f add overview page 2023-06-24 23:55:52 +08:00
MaysWind a62e806175 fix wrong component reference 2023-06-24 21:51:39 +08:00
MaysWind 5bcf5bf93e show mobile url qrcode on desktop page 2023-06-24 21:28:28 +08:00
MaysWind 4f35ba0931 add settings page 2023-06-24 21:27:27 +08:00
MaysWind 2bcdfe778a code refactor 2023-06-24 21:27:19 +08:00
MaysWind c89da1d0f7 code refactor 2023-06-24 21:14:54 +08:00
MaysWind 46a1eda029 add page settings page 2023-06-24 20:01:47 +08:00
MaysWind 1c39819112 add switch to desktop/mobile link 2023-06-24 19:30:28 +08:00
MaysWind 0efe617c03 fix the problem that system default timezone is not browser timezone when custom timezone is set 2023-06-24 18:39:55 +08:00
MaysWind 10df947efe fix npe 2023-06-24 18:30:49 +08:00
MaysWind fb7790ba4a code refactor 2023-06-24 18:27:54 +08:00
MaysWind a9338ed822 code refactor 2023-06-24 14:15:15 +08:00
MaysWind eaab8cdd93 code refactor 2023-06-24 14:07:30 +08:00
MaysWind edfcd0dc6e use autocomplete to replace select 2023-06-24 01:22:35 +08:00
MaysWind 838b56089b remove blank line 2023-06-24 01:22:12 +08:00
MaysWind 178810f908 add user settings page 2023-06-24 01:18:41 +08:00
MaysWind 69f5aca853 fix autocomplete style issue 2023-06-24 00:16:19 +08:00
MaysWind 8e6aece9ae code refactor 2023-06-23 22:23:09 +08:00
MaysWind 59b883ff7f modify style 2023-06-23 21:16:17 +08:00
MaysWind 548d34fbf4 add tooltip 2023-06-23 18:00:31 +08:00
MaysWind bb6345ccfa update vue-datepicker, and make the picker show the calendar view every time opening 2023-06-23 15:48:47 +08:00
MaysWind d59a10f718 fix wrong style 2023-06-23 15:00:38 +08:00
MaysWind aab1c5419a remove unused reference 2023-06-23 14:53:22 +08:00
MaysWind 9b83785b95 modify style 2023-06-23 14:51:07 +08:00
MaysWind 099b710eb1 code refactor 2023-06-23 14:16:37 +08:00
MaysWind a5424afc38 modify style 2023-06-23 13:40:15 +08:00
MaysWind 626325066d code refactor 2023-06-23 13:38:09 +08:00
MaysWind 37195f6008 code refactor 2023-06-23 10:04:33 +08:00
MaysWind fcbc68cefe modify file name 2023-06-23 10:00:05 +08:00
MaysWind 9241953e31 modify style 2023-06-23 01:37:58 +08:00
MaysWind 651a912498 support change theme 2023-06-23 01:24:10 +08:00
MaysWind a05f6fb6b5 modify style 2023-06-23 00:59:14 +08:00
MaysWind d6440d31f2 add exchange rates page 2023-06-23 00:21:00 +08:00
MaysWind 88d5dc2f17 set default timeout setting 2023-06-22 22:54:48 +08:00
MaysWind a17ad85858 add about page 2023-06-22 21:48:19 +08:00
MaysWind 4b49c1f30f add desktop frontend framework 2023-06-22 21:30:18 +08:00
MaysWind a9e36b9a59 modify style 2023-06-22 18:43:39 +08:00
MaysWind 80429bbfb8 code refactor 2023-06-22 16:35:52 +08:00
MaysWind f39e20d7a7 support setting user disabled 2023-06-21 23:57:04 +08:00
MaysWind a03bac5d74 modify style 2023-06-21 23:38:37 +08:00
MaysWind 1aff09598a code refactor 2023-06-21 23:25:56 +08:00
MaysWind 4036a71ee1 update third party dependencies 2023-06-21 22:01:23 +08:00
MaysWind a966be2f46 package framework7 and related dependencies into vendor-mobile.js file 2023-06-21 21:52:31 +08:00
MaysWind a0b3bc1cab fix default text size 2023-06-21 21:50:16 +08:00
MaysWind eb2e2b0a26 show whether data is updated after pull down 2023-06-19 00:12:39 +08:00
MaysWind 55ad7b2e81 code refactor 2023-06-18 21:07:44 +08:00
MaysWind dbcd2897a4 support tomtom map 2023-06-18 21:02:55 +08:00
MaysWind 68a6d1c166 update configuration comment 2023-06-18 20:40:45 +08:00
MaysWind fe82ec6fc2 support OpenStreetMap(Humanitarian), OpenTopoMap, OPNVKarte, CyclOSM 2023-06-18 18:24:11 +08:00
MaysWind 5f2819a961 code refactor 2023-06-18 16:30:19 +08:00
MaysWind 812bfc7cf5 set reference libraries in google map js query 2023-06-18 16:29:30 +08:00
MaysWind d164cafd33 fix npe 2023-06-18 15:53:06 +08:00
MaysWind 3ada4183d9 code refactor 2023-06-18 15:51:57 +08:00
MaysWind fa68621b41 support api proxy for amap 2023-06-18 15:43:27 +08:00
MaysWind 4f2b9d39da code refactor 2023-06-18 09:20:02 +08:00
MaysWind fd01c9269f improve robustness 2023-06-18 01:25:47 +08:00
MaysWind 82d150e53a support amap 2023-06-18 01:24:29 +08:00
MaysWind 251f3fe2da update config comment 2023-06-18 00:26:18 +08:00
MaysWind 4f0e1e6b3d set position and zoom level when init map 2023-06-18 00:06:51 +08:00
MaysWind a5dbf5d4b7 support google map 2023-06-17 23:24:45 +08:00
MaysWind 38baf77c30 support get current language info 2023-06-17 23:07:22 +08:00
MaysWind bfb8b03fc9 add language code 2023-06-17 23:07:11 +08:00
MaysWind 2dd38d9b03 force set default language when specified language not exists, force set locale settings when first set locale settings 2023-06-17 23:04:46 +08:00
MaysWind 307c64bc1e don't write unnecessary info to cookies 2023-06-17 19:44:21 +08:00
MaysWind 782e3a85f9 support baidu map 2023-06-17 19:38:13 +08:00
MaysWind 3bae6e749a support baidu map 2023-06-17 17:47:12 +08:00
MaysWind 530ef6b83e fix default text size is not set to default 2023-06-17 13:09:39 +08:00
MaysWind 5b334eb2d5 show time difference between the transaction timezone and the default timezone on the transaction edit/view page 2023-06-15 01:31:29 +08:00
MaysWind 28dc2e425a fix the problem the end time not equals to the current time in transaction list page when timezone not set to browser timezone 2023-06-15 01:27:15 +08:00
mayswind 83b72e7403 remove duplicated code 2023-06-14 09:12:50 +08:00
MaysWind 01e1f65ffe display preview when drag the range slider 2023-06-14 01:38:52 +08:00
MaysWind 9b15f888e6 improve tree view selection sheet style 2023-06-14 01:36:27 +08:00
MaysWind 171b8afa8e improve text size settings 2023-06-14 01:27:23 +08:00
MaysWind 27f2f9f13a modify transaction edit page loading style 2023-06-14 00:45:49 +08:00
MaysWind 2c3983cead modify transaction list page loading style 2023-06-14 00:45:33 +08:00
MaysWind bebd043d58 add font settings page 2023-06-13 01:33:54 +08:00
MaysWind a1c828fe62 add more supported font size 2023-06-13 01:19:42 +08:00
MaysWind dfb885f38d support setting app font size 2023-06-12 01:39:23 +08:00
MaysWind 702c095544 fix the problem that the last label does not have divider line when filling in a new label 2023-06-11 22:55:12 +08:00
MaysWind b3e886d444 fix npe error 2023-06-11 22:53:32 +08:00
MaysWind 46d85e92cd use pinia to replace vuex, code refactor 2023-06-11 22:08:30 +08:00
MaysWind 0d84f2857f update README 2023-06-10 22:13:54 +08:00
MaysWind ac6c80db90 bump version to 0.4.0 2023-06-10 18:03:03 +08:00
809 changed files with 140844 additions and 27505 deletions
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
'root': true,
'env': {
'node': true
},
'extends': [
'eslint:recommended',
'plugin:vue/vue3-essential'
],
'rules': {
'vue/no-use-v-if-with-v-for': 'off'
}
}
+17
View File
@@ -0,0 +1,17 @@
name: Deploy Docker Image
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Execute custom script
run: |
cat >> deploy.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_DEPLOY_SCRIPTS }}
EOF
chmod +x deploy.sh
./deploy.sh
+17 -8
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -23,28 +23,37 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up the environment
run: |
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
cat >> docker/custom-backend-pre-setup.sh <<EOF
#!/bin/sh
${{ secrets.CUSTOM_BACKEND_PRE_SETUP }}
${{ vars.CUSTOM_BACKEND_PRE_SETUP }}
EOF
cat >> docker/custom-frontend-pre-setup.sh <<EOF
#!/bin/sh
${{ secrets.CUSTOM_FRONTEND_PRE_SETUP }}
${{ vars.CUSTOM_FRONTEND_PRE_SETUP }}
EOF
chmod +x docker/custom-backend-pre-setup.sh
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: ${{ vars.BUILD_RELEASE_PLATFORMS }}
push: true
build-args: |
RELEASE_BUILD=1
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+18 -9
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -23,27 +23,36 @@ jobs:
type=raw,value=latest-snapshot
type=sha,format=short,prefix=SNAPSHOT-
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up the environment
run: |
sed -i 's#FROM #FROM ${{ secrets.DOCKER_REPO }}/mirrors/#g' Dockerfile
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
cat >> docker/custom-backend-pre-setup.sh <<EOF
#!/bin/sh
${{ secrets.CUSTOM_BACKEND_PRE_SETUP }}
${{ vars.CUSTOM_BACKEND_PRE_SETUP }}
EOF
cat >> docker/custom-frontend-pre-setup.sh <<EOF
#!/bin/sh
${{ secrets.CUSTOM_FRONTEND_PRE_SETUP }}
${{ vars.CUSTOM_FRONTEND_PRE_SETUP }}
EOF
chmod +x docker/custom-backend-pre-setup.sh
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
push: true
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+10 -7
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
@@ -24,19 +24,21 @@ jobs:
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
@@ -48,5 +50,6 @@ jobs:
push: true
build-args: |
RELEASE_BUILD=1
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+11 -7
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
@@ -23,19 +23,21 @@ jobs:
type=raw,value=latest-snapshot
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
@@ -45,5 +47,7 @@ jobs:
linux/arm/v7
linux/arm/v6
push: true
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+6 -4
View File
@@ -11,18 +11,20 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: linux/amd64
push: false
push: false
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
+8 -4
View File
@@ -1,7 +1,9 @@
# Build backend binary file
FROM golang:1.20.4-alpine3.17 AS be-builder
FROM golang:1.24.2-alpine3.21 AS be-builder
ARG RELEASE_BUILD
ARG SKIP_TESTS
ENV RELEASE_BUILD=$RELEASE_BUILD
ENV SKIP_TESTS=$SKIP_TESTS
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
COPY . .
RUN docker/backend-build-pre-setup.sh
@@ -9,7 +11,7 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend
# Build frontend files
FROM node:18.16.0-alpine3.17 AS fe-builder
FROM --platform=$BUILDPLATFORM node:22.15.0-alpine3.21 AS fe-builder
ARG RELEASE_BUILD
ENV RELEASE_BUILD=$RELEASE_BUILD
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
@@ -19,7 +21,7 @@ RUN apk add git
RUN ./build.sh frontend
# Package docker image
FROM alpine:3.17.3
FROM alpine:3.21.3
LABEL maintainer="MaysWind <i@mayswind.net>"
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
RUN apk --no-cache add tzdata
@@ -27,11 +29,13 @@ COPY docker/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
RUN mkdir -p /ezbookkeeping && chown 1000:1000 /ezbookkeeping \
&& mkdir -p /ezbookkeeping/data && chown 1000:1000 /ezbookkeeping/data \
&& mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log
&& mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log \
&& mkdir -p /ezbookkeeping/storage && chown 1000:1000 /ezbookkeeping/storage
WORKDIR /ezbookkeeping
COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/ezbookkeeping
COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public
COPY --chown=1000:1000 conf /ezbookkeeping/conf
COPY --chown=1000:1000 templates /ezbookkeeping/templates
COPY --chown=1000:1000 LICENSE /ezbookkeeping/LICENSE
USER 1000:1000
EXPOSE 8080
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2023 MaysWind (i@mayswind.net)
Copyright (c) 2020-2025 MaysWind (i@mayswind.net)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+52 -22
View File
@@ -6,32 +6,46 @@
[![Latest Release](https://img.shields.io/github/release/mayswind/ezbookkeeping.svg?style=flat)](https://github.com/mayswind/ezbookkeeping/releases)
## Introduction
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including sqlite and mysql. With docker, you can just deploy it via one command without complicated configuration.
ezBookkeeping is a lightweight self-hosted personal bookkeeping app with user-friendly interface for both desktop and mobile devices. It supports PWA, you can [add the app homepage to the home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) of your mobile device and use it just like a native app. It's easily to be deployed and configured, you can just deploy it by a single command via Docker. It supports almost all platforms, including Windows, macOS, and Linux, and is compatible with x86, amd64 and ARM hardware architectures. It only requires very few system resources, and you can even run it on a Raspberry Pi device.
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
## Features
1. Open source & Self-hosted
1. Open Source & Self-Hosted
2. Lightweight & Fast
3. Easy to install
* Docker support
* Multiple database support (sqlite, mysql, etc.)
* Multiple os & architecture support (Windows, macOS, Linux & x86, amd64, ARM)
4. User-friendly interface
* Close to native app experience (for mobile device)
* Two-level account & two-level category support
* Plentiful preset categories
* Searching & filtering history records
* Data statistics
3. Easy Installation
* Support Docker
* Support multiple databases (SQLite, MySQL, PostgreSQL, etc.)
* Support multiple operation system & hardware architectures (Windows, macOS, Linux & x86, amd64, ARM)
4. User-Friendly Interface
* Native UI for both desktop and mobile devices
* Support PWA, providing near-native experience for mobile devices
* Dark theme
5. Multiple currency support & automatically updating exchange rates
6. Multiple timezone support
7. Multi-language support
8. Two-factor authentication
9. Application lock (WebAuthn support)
10. Data export
5. Powerful Bookkeeping Features
* Support two-level account
* Support two-level transaction categories and predefined categories
* Support transaction pictures
* Support geographic location tracking and map
* Support recurring transactions
* Search and filter transaction records
* Data visualization and statistical analysis
6. Localization Support
* Multi-language support
* Multi-currency support with automatic exchange rate updates from various financial institutions
* Multi-timezone support
* Customizable date, time, number and currency display formats
7. Security & Reliability
* Two-factor authentication (2FA)
* Login rate limiting
* Application lock (PIN code / WebAuthn)
8. Data Export & Import (CSV, OFX, QFX, QIF, IIF, GnuCash, FireFly III, Beancount, etc.)
## Screenshots
### Mobile Device
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/en.png)
### Desktop Version
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/desktop/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/desktop/en.png)
### Mobile Version
[![ezBookkeeping](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/en.png)
## Installation
### Ship with docker
@@ -48,24 +62,40 @@ Latest Daily Build:
### Install from binary
Latest release: [https://github.com/mayswind/ezbookkeeping/releases](https://github.com/mayswind/ezbookkeeping/releases)
**Linux / macOS**
$ ./ezbookkeeping server run
ezBookkeeping will listen at port 8080 as default. Then you can visit http://{YOUR_HOST_ADDRESS}:8080/ .
**Windows**
> .\ezbookkeeping.exe server run
ezBookkeeping will listen at port 8080 as default. Then you can visit `http://{YOUR_HOST_ADDRESS}:8080/` .
### Build from source
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
**Linux / macOS**
$ ./build.sh package -o ezbookkeeping.tar.gz
All the files will be packaged in `ezbookkeeping.tar.gz`.
**Windows**
> .\build.bat package -o ezbookkeeping.zip
All the files will be packaged in `ezbookkeeping.zip`.
You can also build docker image, make sure you have [docker](https://www.docker.com/) installed, then follow these steps:
**Linux**
$ ./build.sh docker
## Documents
1. [English](http://ezbookkeeping.mayswind.net)
1. [简体中文 (Simplified Chinese)](http://ezbookkeeping.mayswind.net/zh_Hans)
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
## License
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
+271
View File
@@ -0,0 +1,271 @@
@echo off
set "TYPE="
set "NO_LINT=0"
set "NO_TEST=0"
set "SKIP_TESTS=%SKIP_TESTS%"
set "RELEASE=%RELEASE_BUILD%"
set "RELEASE_TYPE=unknown"
set "VERSION="
set "COMMIT_HASH="
set "BUILD_UNIXTIME="
set "BUILD_DATE="
set "PACKAGE_FILENAME="
for /f %%a in ('"prompt $E$S & echo on & for %%b in (1) do rem"') do set "ESC=%%a"
if "%~1"=="" call :show_help & goto :end
goto :pre_parse_args
:echo_red
echo %ESC%[91m%~1%ESC%[0m
goto :eof
:set_unixtime
setlocal enableextensions
for /f %%x in ('wmic path win32_utctime get /format:list ^| findstr "="') do set %%x
set /a z=(14-100%Month%%%100)/12, y=10000%Year%%%10000-z
set /a ut=y*365+y/4-y/100+y/400+(153*(100%Month%%%100+12*z-3)+2)/5+Day-719469
set /a ut=ut*86400+100%Hour%%%100*3600+100%Minute%%%100*60+100%Second%%%100
endlocal & set "%1=%ut%" & goto :eof
:set_date
setlocal enableextensions
for /f %%x in ('wmic path win32_localtime get /format:list ^| findstr "="') do set %%x
if %Month% lss 10 set "Month=0%Month%"
if %Day% lss 10 set "Day=0%Day%"
endlocal & set "%1=%Year%%Month%%Day%" & goto :eof
:check_dependency
if "%~1"=="" goto :eof
where /q %~1 || call :echo_red "Error: "%~1" is required." && goto :end
shift
goto :check_dependency
:show_help
echo ezBookkeeping build script for Windows
echo.
echo Usage:
echo build.cmd type [options]
echo.
echo Types:
echo backend Build backend binary file
echo frontend Build frontend files
echo package Build package archive
echo.
echo Options:
echo /r, --release Build release (The script will use environment variable "RELEASE_BUILD" to detect whether this is release building by default)
echo /o, --output ^<filename^> Package file name (For "package" type only)
echo --no-lint Do not execute lint check before building
echo --no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
echo /h, --help Show help
goto :eof
:pre_parse_args
if "%~1"=="" goto :post_parse_args
if /i "%~1"=="backend" set "TYPE=%~1" & shift
if /i "%~1"=="frontend" set "TYPE=%~1" & shift
if /i "%~1"=="package" set "TYPE=%~1" & shift
:parse_args
if "%~1"=="" goto :post_parse_args
if /i "%~1"=="/r" set "RELEASE=1" & shift & goto :parse_args
if /i "%~1"=="-r" set "RELEASE=1" & shift & goto :parse_args
if /i "%~1"=="--release" set "RELEASE=1" & shift & goto :parse_args
if /i "%~1"=="/o" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
if /i "%~1"=="-o" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
if /i "%~1"=="--output" set "PACKAGE_FILENAME=%~2" & shift & shift & goto :parse_args
if /i "%~1"=="--no-lint" set "NO_LINT=1" & shift & goto :parse_args
if /i "%~1"=="--no-test" set "NO_TEST=1" & shift & goto :parse_args
if /i "%~1"=="/h" call :show_help & goto :end
if /i "%~1"=="-h" call :show_help & goto :end
if /i "%~1"=="--help" call :show_help & goto :end
call :echo_red "Invalid argument: %~1" & call :show_help & goto :end
:post_parse_args
if "%RELEASE%"=="" set "RELEASE=0"
if "%RELEASE%"=="0" (
set "RELEASE_TYPE=snapshot"
) else (
set "RELEASE_TYPE=release"
)
:check_type_dependencies
if not defined TYPE call :echo_red "Error: No specified type" & call :show_help & goto :end
call :check_dependency "git"
if "%TYPE%"=="backend" call :check_dependency "go" "gcc"
if "%TYPE%"=="frontend" call :check_dependency "node" "npm"
if "%TYPE%"=="package" call :check_dependency "go" "gcc" "node" "npm" "7z"
if not "%errorlevel%"=="0" goto :end
:set_build_parameters
for /f "tokens=2 delims=:" %%x in ('findstr "\"version\": \"*\"," package.json') do set "VERSION=%%x"
set VERSION=%VERSION: =%
set VERSION=%VERSION:,=%
set VERSION=%VERSION:"=%
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x"
call :set_unixtime BUILD_UNIXTIME
call :set_date BUILD_DATE
:main
if "%TYPE%"=="backend" call :build_backend & goto :end
if "%TYPE%"=="frontend" call :build_frontend & goto :end
if "%TYPE%"=="package" call :build_package & goto :end
goto :end
:build_backend
setlocal enabledelayedexpansion
echo Pulling backend dependencies...
call go get .
if "%NO_LINT%"=="0" (
echo Executing backend lint checking...
call go vet -v .\...
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass lint checking"
goto :end
)
)
if "%NO_TEST%"=="0" (
echo Executing backend unit testing...
call go clean -cache
if "%SKIP_TESTS%"=="" (
call go test .\... -v
) else (
echo (Skip unit test "%SKIP_TESTS%")
call go test .\... -v -skip "%SKIP_TESTS%"
)
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass unit testing"
goto :end
)
)
endlocal
set "CGO_ENABLED=1"
setlocal
set "backend_build_extra_arguments=-X main.Version=%VERSION%"
set "backend_build_extra_arguments=%backend_build_extra_arguments% -X main.CommitHash=%COMMIT_HASH%"
if "%RELEASE%"=="0" (
set "backend_build_extra_arguments=%backend_build_extra_arguments% -X main.BuildUnixTime=%BUILD_UNIXTIME%"
)
echo Building backend binary file (%RELEASE_TYPE%)...
call go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' %backend_build_extra_arguments%" -o ezbookkeeping.exe ezbookkeeping.go
endlocal
set "CGO_ENABLED="
goto :eof
:build_frontend
setlocal enabledelayedexpansion
echo Pulling frontend dependencies...
call npm install
if "%NO_LINT%"=="0" (
echo Executing frontend lint checking...
call npm run lint
if !errorlevel! neq 0 (
call :echo_red "Error: Failed to pass lint checking"
goto :end
)
)
endlocal
echo Building frontend files(%RELEASE_TYPE%)...
if "%RELEASE%"=="0" (
set "buildUnixTime=%BUILD_UNIXTIME%"
call npm run build
set "buildUnixTime="
) else (
call npm run build
)
goto :eof
:build_package
setlocal enabledelayedexpansion
set "package_file_name=%VERSION%"
if "%RELEASE%"=="0" (
set "build_date="
set "package_file_name=%package_file_name%-%build_date%"
)
set "package_file_name=ezbookkeeping-%package_file_name%-windows.zip"
if defined PACKAGE_FILENAME set "package_file_name=%PACKAGE_FILENAME%"
echo Building package archive "%package_file_name%" (%RELEASE_TYPE%)...
call :build_backend
if !errorlevel! neq 0 (
goto :end
)
call :build_frontend
if !errorlevel! neq 0 (
goto :end
)
rmdir package /s /q
mkdir package
mkdir package\data
mkdir package\storage
mkdir package\log
xcopy ezbookkeeping.exe package\
xcopy dist package\public /e /i
xcopy conf package\conf /e /i
xcopy templates package\templates /e /i
xcopy LICENSE package\
cd package
if !errorlevel! neq 0 (
call :echo_red "Error: Build Failed"
goto :end
)
call 7z a -r -tzip -mx9 ..\%package_file_name% package *
cd ..
endlocal
goto :eof
:end
set "TYPE="
set "NO_LINT="
set "NO_TEST="
set "RELEASE="
set "RELEASE_TYPE="
set "VERSION="
set "COMMIT_HASH="
set "BUILD_UNIXTIME="
set "BUILD_DATE="
set "PACKAGE_FILENAME="
exit /B
+14 -2
View File
@@ -3,6 +3,7 @@
TYPE=""
NO_LINT="0"
NO_TEST="0"
SKIP_TESTS="${SKIP_TESTS}"
RELEASE=${RELEASE_BUILD:-"0"}
RELEASE_TYPE="unknown"
VERSION=""
@@ -43,7 +44,7 @@ Options:
-o, --output <filename> Package file name (For "package" type only)
-t, --tag Docker tag (For "docker" type only)
--no-lint Do not execute lint check before building
--no-test Do not execute unit testing before building
--no-test Do not execute unit testing before building (You can use environment variable "SKIP_TESTS" to skip specified tests)
-h, --help Show help
EOF
}
@@ -121,6 +122,9 @@ set_build_parameters() {
}
build_backend() {
echo "Pulling backend dependencies..."
go get .
if [ "$NO_LINT" = "0" ]; then
echo "Executing backend lint checking..."
go vet -v ./...
@@ -134,7 +138,13 @@ build_backend() {
if [ "$NO_TEST" = "0" ]; then
echo "Executing backend unit testing..."
go clean -cache
go test ./... -v
if [ -z "$SKIP_TESTS" ]; then
go test ./... -v
else
echo "(Skip unit test \"$SKIP_TESTS\")"
go test ./... -v -skip "$SKIP_TESTS"
fi
if [ "$?" != "0" ]; then
echo_red "Error: Failed to pass unit testing"
@@ -199,10 +209,12 @@ build_package() {
rm -rf package
mkdir package
mkdir package/data
mkdir package/storage
mkdir package/log
cp ezbookkeeping package/
cp -R dist package/public
cp -R conf package/conf
cp -R templates package/templates
cp LICENSE package/
cd package || { echo_red "Error: Build Failed"; exit 1; }
+16
View File
@@ -0,0 +1,16 @@
package cmd
import (
"context"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func bindAction(fn core.CliHandlerFunc) cli.ActionFunc {
return func(ctx context.Context, cmd *cli.Command) error {
c := core.WrapCilContext(ctx, cmd)
return fn(c)
}
}
+100
View File
@@ -0,0 +1,100 @@
package cmd
import (
"fmt"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/cron"
"github.com/mayswind/ezbookkeeping/pkg/log"
)
// CronJobs represents the cron command
var CronJobs = &cli.Command{
Name: "cron",
Usage: "ezBookkeeping cron job utilities",
Commands: []*cli.Command{
{
Name: "list",
Usage: "List all enabled cron jobs",
Action: bindAction(listAllCronJobs),
},
{
Name: "run",
Usage: "Run specified cron job",
Action: bindAction(runCronJob),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Required: true,
Usage: "Cron job name",
},
},
},
},
}
func listAllCronJobs(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, false)
if err != nil {
log.CliErrorf(c, "[cron_jobs.listAllCronJobs] initializes cron job scheduler failed, because %s", err.Error())
return err
}
cronJobs := cron.Container.GetAllJobs()
if len(cronJobs) < 1 {
log.CliErrorf(c, "[cron_jobs.listAllCronJobs] there are no enabled cron jobs")
return err
}
for i := 0; i < len(cronJobs); i++ {
if i > 0 {
fmt.Printf("---\n")
}
cronJob := cronJobs[i]
fmt.Printf("[Name] %s\n", cronJob.Name)
fmt.Printf("[Description] %s\n", cronJob.Description)
fmt.Printf("[Interval] Every %s\n", cronJob.Period.GetInterval())
}
return nil
}
func runCronJob(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, false)
if err != nil {
log.CliErrorf(c, "[cron_jobs.runCronJob] initializes cron job scheduler failed, because %s", err.Error())
return err
}
jobName := c.String("name")
err = cron.Container.SyncRunJobNow(jobName)
if err != nil {
log.CliErrorf(c, "[cron_jobs.runCronJob] failed to run cron job \"%s\", because %s", jobName, err.Error())
return err
}
log.CliInfof(c, "[cron_jobs.runCronJob] run cron job \"%s\" successfully", jobName)
return nil
}
+43 -18
View File
@@ -1,8 +1,9 @@
package cmd
import (
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -12,36 +13,36 @@ import (
var Database = &cli.Command{
Name: "database",
Usage: "ezBookkeeping database maintenance",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "update",
Usage: "Update database structure",
Action: updateDatabaseStructure,
Action: bindAction(updateDatabaseStructure),
},
},
}
func updateDatabaseStructure(c *cli.Context) error {
func updateDatabaseStructure(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
log.BootInfof("[database.updateDatabaseStructure] starting maintaining")
log.CliInfof(c, "[database.updateDatabaseStructure] starting maintaining")
err = updateAllDatabaseTablesStructure()
err = updateAllDatabaseTablesStructure(c)
if err != nil {
log.BootErrorf("[database.updateDatabaseStructure] update database table structure failed, because %s", err.Error())
log.CliErrorf(c, "[database.updateDatabaseStructure] update database table structure failed, because %s", err.Error())
return err
}
log.BootInfof("[database.updateDatabaseStructure] all tables maintained successfully")
log.CliInfof(c, "[database.updateDatabaseStructure] all tables maintained successfully")
return nil
}
func updateAllDatabaseTablesStructure() error {
func updateAllDatabaseTablesStructure(c *core.CliContext) error {
var err error
err = datastore.Container.UserStore.SyncStructs(new(models.User))
@@ -50,7 +51,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] user table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user table maintained successfully")
err = datastore.Container.UserStore.SyncStructs(new(models.TwoFactor))
@@ -58,7 +59,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] two factor table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] two-factor table maintained successfully")
err = datastore.Container.UserStore.SyncStructs(new(models.TwoFactorRecoveryCode))
@@ -66,7 +67,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] two factor recovery code table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] two-factor recovery code table maintained successfully")
err = datastore.Container.TokenStore.SyncStructs(new(models.TokenRecord))
@@ -74,7 +75,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] token record table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] token record table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.Account))
@@ -82,7 +83,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] account table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] account table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.Transaction))
@@ -90,7 +91,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionCategory))
@@ -98,7 +99,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction category table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTag))
@@ -106,7 +107,7 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction tag table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTagIndex))
@@ -114,7 +115,31 @@ func updateAllDatabaseTablesStructure() error {
return err
}
log.BootInfof("[database.updateAllDatabaseTablesStructure] transaction tag index table maintained successfully")
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction tag index table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionTemplate))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction template table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.TransactionPictureInfo))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] transaction picture table maintained successfully")
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserCustomExchangeRate))
if err != nil {
return err
}
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user custom exchange rate table maintained successfully")
return nil
}
+82 -14
View File
@@ -4,77 +4,142 @@ import (
"encoding/json"
"os"
"github.com/urfave/cli/v2"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/storage"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
)
func initializeSystem(c *cli.Context) (*settings.Config, error) {
func initializeSystem(c *core.CliContext) (*settings.Config, error) {
var err error
configFilePath := c.String("conf-path")
isDisableBootLog := c.Bool("no-boot-log")
if configFilePath != "" {
if _, err = os.Stat(configFilePath); err != nil {
log.BootErrorf("[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration from custom config path %s, because file not exists", configFilePath)
}
return nil, err
}
log.BootInfof("[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
if !isDisableBootLog {
log.BootInfof(c, "[initializer.initializeSystem] will loading configuration from custom config path %s", configFilePath)
}
} else {
configFilePath, err = settings.GetDefaultConfigFilePath()
if err != nil {
log.BootErrorf("[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] cannot get default configuration path, because %s", err.Error())
}
return nil, err
}
log.BootInfof("[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
if !isDisableBootLog {
log.BootInfof(c, "[initializer.initializeSystem] will load configuration from default config path %s", configFilePath)
}
}
config, err := settings.LoadConfiguration(configFilePath)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] cannot load configuration, because %s", err.Error())
}
return nil, err
}
if config.SecretKeyNoSet {
log.BootWarnf(c, "[initializer.initializeSystem] \"secret_key\" in config file is not set, please change it to keep your user data safe")
}
settings.SetCurrentConfig(config)
err = datastore.InitializeDataStore(config)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes data store failed, because %s", err.Error())
}
return nil, err
}
err = log.SetLoggerConfiguration(config)
err = log.SetLoggerConfiguration(config, isDisableBootLog)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] sets logger configuration failed, because %s", err.Error())
}
return nil, err
}
err = storage.InitializeStorageContainer(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes object storage failed, because %s", err.Error())
}
return nil, err
}
err = uuid.InitializeUuidGenerator(config)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes uuid generator failed, because %s", err.Error())
}
return nil, err
}
err = duplicatechecker.InitializeDuplicateChecker(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes duplicate checker failed, because %s", err.Error())
}
return nil, err
}
err = avatars.InitializeAvatarProvider(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes avatar provider failed, because %s", err.Error())
}
return nil, err
}
err = mail.InitializeMailer(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes mailer failed, because %s", err.Error())
}
return nil, err
}
err = exchangerates.InitializeExchangeRatesDataSource(config)
if err != nil {
log.BootErrorf("[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes exchange rates data source failed, because %s", err.Error())
}
return nil, err
}
cfgJson, _ := json.Marshal(getConfigWithoutSensitiveData(config))
log.BootInfof("[initializer.initializeSystem] has loaded configuration %s", cfgJson)
if !isDisableBootLog {
log.BootInfof(c, "[initializer.initializeSystem] has loaded configuration %s", cfgJson)
}
return config, nil
}
@@ -88,7 +153,10 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
}
clonedConfig.DatabaseConfig.DatabasePassword = "****"
clonedConfig.SMTPConfig.SMTPPasswd = "****"
clonedConfig.MinIOConfig.SecretAccessKey = "****"
clonedConfig.SecretKey = "****"
clonedConfig.AmapApplicationSecret = "****"
return clonedConfig
}
+5 -4
View File
@@ -3,8 +3,9 @@ package cmd
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -12,11 +13,11 @@ import (
var SecurityUtils = &cli.Command{
Name: "security",
Usage: "ezBookkeeping security utilities",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "gen-secret-key",
Usage: "Generate a random secret key",
Action: genSecretKey,
Action: bindAction(genSecretKey),
Flags: []cli.Flag{
&cli.IntFlag{
Name: "length",
@@ -30,7 +31,7 @@ var SecurityUtils = &cli.Command{
},
}
func genSecretKey(c *cli.Context) error {
func genSecretKey(c *core.CliContext) error {
length := c.Int("length")
if length <= 0 {
+544 -43
View File
@@ -2,13 +2,15 @@ package cmd
import (
"fmt"
"github.com/mayswind/ezbookkeeping/pkg/models"
"os"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
clis "github.com/mayswind/ezbookkeeping/pkg/cli"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -16,11 +18,11 @@ import (
var UserData = &cli.Command{
Name: "userdata",
Usage: "ezBookkeeping user data maintenance",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "user-add",
Usage: "Add new user",
Action: addNewUser,
Action: bindAction(addNewUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -57,7 +59,7 @@ var UserData = &cli.Command{
{
Name: "user-get",
Usage: "Get specified user info",
Action: getUserInfo,
Action: bindAction(getUserInfo),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -70,7 +72,7 @@ var UserData = &cli.Command{
{
Name: "user-modify-password",
Usage: "Modify user password",
Action: modifyUserPassword,
Action: bindAction(modifyUserPassword),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -86,10 +88,132 @@ var UserData = &cli.Command{
},
},
},
{
Name: "user-enable",
Usage: "Enable specified user",
Action: bindAction(enableUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-disable",
Usage: "Disable specified user",
Action: bindAction(disableUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-set-restrict-features",
Usage: "Set restrictions of user features",
Action: bindAction(setUserFeatureRestriction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
&cli.StringFlag{
Name: "features",
Aliases: []string{"t"},
Required: true,
Usage: "Specific feature types (feature types separated by commas)",
},
},
},
{
Name: "user-add-restrict-features",
Usage: "Add restrictions of user features",
Action: bindAction(addUserFeatureRestriction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
&cli.StringFlag{
Name: "features",
Aliases: []string{"t"},
Required: true,
Usage: "Specific feature types (feature types separated by commas)",
},
},
},
{
Name: "user-remove-restrict-features",
Usage: "Remove restrictions of user features",
Action: bindAction(removeUserFeatureRestriction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
&cli.StringFlag{
Name: "features",
Aliases: []string{"t"},
Required: true,
Usage: "Specific feature types (feature types separated by commas)",
},
},
},
{
Name: "user-resend-verify-email",
Usage: "Resend user verify email",
Action: bindAction(resendUserVerifyEmail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-set-email-verified",
Usage: "Set user email address verified",
Action: bindAction(setUserEmailVerified),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-set-email-unverified",
Usage: "Set user email address unverified",
Action: bindAction(setUserEmailUnverified),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-delete",
Usage: "Delete specified user",
Action: deleteUser,
Action: bindAction(deleteUser),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -102,7 +226,7 @@ var UserData = &cli.Command{
{
Name: "user-2fa-disable",
Usage: "Disable user 2fa setting",
Action: disableUser2FA,
Action: bindAction(disableUser2FA),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -115,7 +239,20 @@ var UserData = &cli.Command{
{
Name: "user-session-list",
Usage: "List all user sessions",
Action: listUserTokens,
Action: bindAction(listUserTokens),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-session-new",
Usage: "Create new session for user",
Action: bindAction(createNewUserToken),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -128,7 +265,20 @@ var UserData = &cli.Command{
{
Name: "user-session-clear",
Usage: "Clear user all sessions",
Action: clearUserTokens,
Action: bindAction(clearUserTokens),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "send-password-reset-mail",
Usage: "Send password reset mail",
Action: bindAction(sendPasswordResetMail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -141,7 +291,7 @@ var UserData = &cli.Command{
{
Name: "transaction-check",
Usage: "Check whether user all transactions and accounts are correct",
Action: checkUserTransactionAndAccount,
Action: bindAction(checkUserTransactionAndAccount),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -151,10 +301,48 @@ var UserData = &cli.Command{
},
},
},
{
Name: "transaction-tag-index-fix-transaction-time",
Usage: "Fix the transaction tag index data which does not have transaction time",
Action: bindAction(fixTransactionTagIndexNotHaveTransactionTime),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "transaction-import",
Usage: "Import transactions to specified user",
Action: bindAction(importUserTransaction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
Required: true,
Usage: "Specific import file path (e.g. transaction.csv)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Required: true,
Usage: "Import file type (supports \"ezbookkeeping_csv\", \"ezbookkeeping_tsv\")",
},
},
},
{
Name: "transaction-export",
Usage: "Export user all transactions to csv file",
Action: exportUserTransaction,
Usage: "Export user all transactions to file",
Action: bindAction(exportUserTransaction),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@@ -168,12 +356,18 @@ var UserData = &cli.Command{
Required: true,
Usage: "Specific exported file path (e.g. transaction.csv)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Required: false,
Usage: "Export file type, support csv or tsv, default is csv",
},
},
},
},
}
func addNewUser(c *cli.Context) error {
func addNewUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -189,7 +383,7 @@ func addNewUser(c *cli.Context) error {
user, err := clis.UserData.AddNewUser(c, username, email, nickname, password, defaultCurrency)
if err != nil {
log.BootErrorf("[user_data.addNewUser] error occurs when adding new user")
log.CliErrorf(c, "[user_data.addNewUser] error occurs when adding new user")
return err
}
@@ -198,7 +392,7 @@ func addNewUser(c *cli.Context) error {
return nil
}
func getUserInfo(c *cli.Context) error {
func getUserInfo(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -209,7 +403,7 @@ func getUserInfo(c *cli.Context) error {
user, err := clis.UserData.GetUserByUsername(c, username)
if err != nil {
log.BootErrorf("[user_data.getUserInfo] error occurs when getting user data")
log.CliErrorf(c, "[user_data.getUserInfo] error occurs when getting user data")
return err
}
@@ -218,7 +412,7 @@ func getUserInfo(c *cli.Context) error {
return nil
}
func modifyUserPassword(c *cli.Context) error {
func modifyUserPassword(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -230,16 +424,211 @@ func modifyUserPassword(c *cli.Context) error {
err = clis.UserData.ModifyUserPassword(c, username, password)
if err != nil {
log.BootErrorf("[user_data.modifyUserPassword] error occurs when modifying user password")
log.CliErrorf(c, "[user_data.modifyUserPassword] error occurs when modifying user password")
return err
}
log.BootInfof("[user_data.modifyUserPassword] password of user \"%s\" has been changed", username)
log.CliInfof(c, "[user_data.modifyUserPassword] password of user \"%s\" has been changed", username)
return nil
}
func deleteUser(c *cli.Context) error {
func sendPasswordResetMail(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.SendPasswordResetMail(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.sendPasswordResetMail] error occurs when sending password reset email")
return err
}
log.CliInfof(c, "[user_data.sendPasswordResetMail] a password reset email for user \"%s\" has been sent", username)
return nil
}
func enableUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.EnableUser(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.enableUser] error occurs when setting user enabled")
return err
}
log.CliInfof(c, "[user_data.enableUser] user \"%s\" has been set enabled", username)
return nil
}
func disableUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.DisableUser(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.disableUser] error occurs when setting user disabled")
return err
}
log.CliInfof(c, "[user_data.disableUser] user \"%s\" has been set disabled", username)
return nil
}
func setUserFeatureRestriction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
err = clis.UserData.SetUserFeatureRestrictions(c, username, featureRestriction)
if err != nil {
log.CliErrorf(c, "[user_data.setUserFeatureRestriction] error occurs when setting user feature restriction")
return err
}
log.CliInfof(c, "[user_data.setUserFeatureRestriction] user \"%s\" has been set new feature restriction", username)
return nil
}
func addUserFeatureRestriction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
if featureRestriction < 1 {
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] nothing has been modified")
return nil
}
err = clis.UserData.AddUserFeatureRestrictions(c, username, featureRestriction)
if err != nil {
log.CliErrorf(c, "[user_data.addUserFeatureRestriction] error occurs when adding user feature restriction")
return err
}
log.CliInfof(c, "[user_data.addUserFeatureRestriction] user \"%s\" has been add new feature restriction", username)
return nil
}
func removeUserFeatureRestriction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
featureRestriction := core.ParseUserFeatureRestrictions(c.String("features"))
if featureRestriction < 1 {
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] nothing has been modified")
return nil
}
err = clis.UserData.RemoveUserFeatureRestrictions(c, username, featureRestriction)
if err != nil {
log.CliErrorf(c, "[user_data.removeUserFeatureRestriction] error occurs when removing user feature restriction")
return err
}
log.CliInfof(c, "[user_data.removeUserFeatureRestriction] user \"%s\" has been removed new feature restriction", username)
return nil
}
func resendUserVerifyEmail(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.ResendVerifyEmail(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.resendUserVerifyEmail] error occurs when resending user verify email")
return err
}
log.CliInfof(c, "[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username)
return nil
}
func setUserEmailVerified(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.SetUserEmailVerified(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.setUserEmailVerified] error occurs when setting user email address verified")
return err
}
log.CliInfof(c, "[user_data.setUserEmailVerified] user \"%s\" email address has been set verified", username)
return nil
}
func setUserEmailUnverified(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.SetUserEmailUnverified(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.setUserEmailUnverified] error occurs when setting user email address unverified")
return err
}
log.CliInfof(c, "[user_data.setUserEmailUnverified] user \"%s\" email address has been set unverified", username)
return nil
}
func deleteUser(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -250,16 +639,16 @@ func deleteUser(c *cli.Context) error {
err = clis.UserData.DeleteUser(c, username)
if err != nil {
log.BootErrorf("[user_data.deleteUser] error occurs when deleting user")
log.CliErrorf(c, "[user_data.deleteUser] error occurs when deleting user")
return err
}
log.BootInfof("[user_data.deleteUser] user \"%s\" has been deleted", username)
log.CliInfof(c, "[user_data.deleteUser] user \"%s\" has been deleted", username)
return nil
}
func disableUser2FA(c *cli.Context) error {
func disableUser2FA(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -270,16 +659,16 @@ func disableUser2FA(c *cli.Context) error {
err = clis.UserData.DisableUserTwoFactorAuthorization(c, username)
if err != nil {
log.BootErrorf("[user_data.disableUser2FA] error occurs when disabling user two factor authorization")
log.CliErrorf(c, "[user_data.disableUser2FA] error occurs when disabling user two-factor authorization")
return err
}
log.BootInfof("[user_data.disableUser2FA] two factor authorization of user \"%s\" has been disabled", username)
log.CliInfof(c, "[user_data.disableUser2FA] two-factor authorization of user \"%s\" has been disabled", username)
return nil
}
func listUserTokens(c *cli.Context) error {
func listUserTokens(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -290,7 +679,7 @@ func listUserTokens(c *cli.Context) error {
tokens, err := clis.UserData.ListUserTokens(c, username)
if err != nil {
log.BootErrorf("[user_data.listUserTokens] error occurs when getting user tokens")
log.CliErrorf(c, "[user_data.listUserTokens] error occurs when getting user tokens")
return err
}
@@ -305,7 +694,28 @@ func listUserTokens(c *cli.Context) error {
return nil
}
func clearUserTokens(c *cli.Context) error {
func createNewUserToken(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
return err
}
printTokenInfo(token)
fmt.Printf("[NewToken] %s\n", tokenString)
return nil
}
func clearUserTokens(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -316,16 +726,16 @@ func clearUserTokens(c *cli.Context) error {
err = clis.UserData.ClearUserTokens(c, username)
if err != nil {
log.BootErrorf("[user_data.clearUserTokens] error occurs when clearing user tokens")
log.CliErrorf(c, "[user_data.clearUserTokens] error occurs when clearing user tokens")
return err
}
log.BootInfof("[user_data.clearUserTokens] all tokens of user \"%s\" has been cleared", username)
log.CliInfof(c, "[user_data.clearUserTokens] all tokens of user \"%s\" has been cleared", username)
return nil
}
func checkUserTransactionAndAccount(c *cli.Context) error {
func checkUserTransactionAndAccount(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -334,21 +744,44 @@ func checkUserTransactionAndAccount(c *cli.Context) error {
username := c.String("username")
log.BootInfof("[user_data.checkUserTransactionAndAccount] starting checking user \"%s\" data", username)
log.CliInfof(c, "[user_data.checkUserTransactionAndAccount] starting checking user \"%s\" data", username)
_, err = clis.UserData.CheckTransactionAndAccount(c, username)
if err != nil {
log.BootErrorf("[user_data.checkUserTransactionAndAccount] error occurs when checking user data")
log.CliErrorf(c, "[user_data.checkUserTransactionAndAccount] error occurs when checking user data")
return err
}
log.BootInfof("[user_data.checkUserTransactionAndAccount] user transactions and accounts data has been checked successfully, there is no problem with user data")
log.CliInfof(c, "[user_data.checkUserTransactionAndAccount] user transactions and accounts data has been checked successfully, there is no problem with user data")
return nil
}
func exportUserTransaction(c *cli.Context) error {
func fixTransactionTagIndexNotHaveTransactionTime(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] starting fixing user \"%s\" transaction tag index data", username)
_, err = clis.UserData.FixTransactionTagIndexWithTransactionTime(c, username)
if err != nil {
log.CliErrorf(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] error occurs when fixing user data")
return err
}
log.CliInfof(c, "[user_data.fixTransactionTagIndexNotHaveTransactionTime] user transaction tag index data has been fixed successfully")
return nil
}
func exportUserTransaction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
@@ -357,36 +790,95 @@ func exportUserTransaction(c *cli.Context) error {
username := c.String("username")
filePath := c.String("file")
fileType := c.String("type")
if fileType == "" {
fileType = "csv"
}
if fileType != "csv" && fileType != "tsv" {
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
return errs.ErrNotSupported
}
if filePath == "" {
log.BootErrorf("[user_data.exportUserTransaction] export file path is not specified")
log.CliErrorf(c, "[user_data.exportUserTransaction] export file path is unspecified")
return os.ErrNotExist
}
fileExists, err := utils.IsExists(filePath)
if fileExists {
log.BootErrorf("[user_data.exportUserTransaction] specified file path already exists")
log.CliErrorf(c, "[user_data.exportUserTransaction] specified file path already exists")
return os.ErrExist
}
log.BootInfof("[user_data.exportUserTransaction] starting exporting user \"%s\" data", username)
log.CliInfof(c, "[user_data.exportUserTransaction] starting exporting user \"%s\" data", username)
content, err := clis.UserData.ExportTransaction(c, username)
content, err := clis.UserData.ExportTransaction(c, username, fileType)
if err != nil {
log.BootErrorf("[user_data.exportUserTransaction] error occurs when exporting user data")
log.CliErrorf(c, "[user_data.exportUserTransaction] error occurs when exporting user data")
return err
}
err = utils.WriteFile(filePath, content)
if err != nil {
log.BootErrorf("[user_data.exportUserTransaction] failed to write to %s", filePath)
log.CliErrorf(c, "[user_data.exportUserTransaction] failed to write to %s", filePath)
return err
}
log.BootInfof("[user_data.exportUserTransaction] user transactions have been exported to %s", filePath)
log.CliInfof(c, "[user_data.exportUserTransaction] user transactions have been exported to %s", filePath)
return nil
}
func importUserTransaction(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
filePath := c.String("file")
filetype := c.String("type")
if filePath == "" {
log.CliErrorf(c, "[user_data.importUserTransaction] import file path is not specified")
return os.ErrNotExist
}
fileExists, err := utils.IsExists(filePath)
if !fileExists {
log.CliErrorf(c, "[user_data.importUserTransaction] import file does not exist")
return os.ErrExist
}
if filetype != "ezbookkeeping_csv" && filetype != "ezbookkeeping_tsv" {
log.CliErrorf(c, "[user_data.importUserTransaction] unknown file type \"%s\"", filetype)
return errs.ErrImportFileTypeNotSupported
}
data, err := os.ReadFile(filePath)
if err != nil {
log.CliErrorf(c, "[user_data.importUserTransaction] failed to load import file")
return err
}
log.CliInfof(c, "[user_data.importUserTransaction] start importing transactions to user \"%s\"", username)
err = clis.UserData.ImportTransaction(c, username, filetype, data)
if err != nil {
log.CliErrorf(c, "[user_data.importUserTransaction] error occurs when importing user data")
return err
}
log.CliInfof(c, "[user_data.importUserTransaction] transactions have been imported to user \"%s\"", username)
return nil
}
@@ -407,6 +899,14 @@ func printUserInfo(user *models.User) {
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
fmt.Printf("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
fmt.Printf("[FeatureRestriction] %s (%d)\n", user.FeatureRestriction, user.FeatureRestriction)
fmt.Printf("[Deleted] %t\n", user.Deleted)
fmt.Printf("[EmailVerified] %t\n", user.EmailVerified)
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(user.CreatedUnixTime), user.CreatedUnixTime)
@@ -427,5 +927,6 @@ func printUserInfo(user *models.User) {
func printTokenInfo(token *models.TokenRecord) {
fmt.Printf("[CreatedAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.CreatedUnixTime), token.CreatedUnixTime)
fmt.Printf("[ExpiredAt] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.ExpiredUnixTime), token.ExpiredUnixTime)
fmt.Printf("[LastSeen] %s (%d)\n", utils.FormatUnixTimeToLongDateTimeInServerTimezone(token.LastSeenUnixTime), token.LastSeenUnixTime)
fmt.Printf("[UserAgent] %s\n", token.UserAgent)
}
+130
View File
@@ -0,0 +1,130 @@
package cmd
import (
"encoding/binary"
"fmt"
"net"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/requestid"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// Utilities represents the utilities command
var Utilities = &cli.Command{
Name: "utility",
Usage: "ezBookkeeping utilities",
Commands: []*cli.Command{
{
Name: "parse-default-request-id",
Usage: "Parse a request id which is generated by default request generator and show the details",
Action: bindAction(parseRequestId),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Required: true,
Usage: "Request ID",
},
},
},
{
Name: "send-test-mail",
Usage: "Send an email to specified e-mail address",
Action: bindAction(sendTestMail),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "to",
Required: true,
Usage: "To e-mail address",
},
},
},
},
}
func parseRequestId(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
err = requestid.InitializeRequestIdGenerator(c, config)
defaultGenerator, err := requestid.NewDefaultRequestIdGenerator(c, config)
if err != nil {
return err
}
requestId := c.String("id")
requestIdInfo, err := defaultGenerator.ParseRequestIdInfo(requestId)
if err != nil {
return err
}
newRequestId := defaultGenerator.GenerateRequestId(net.IPv4zero.String(), 0)
newRequestIdInfo, err := defaultGenerator.ParseRequestIdInfo(newRequestId)
printRequestIdInfo(requestId, requestIdInfo, newRequestIdInfo)
return nil
}
func sendTestMail(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
if !config.EnableSMTP || mail.Container.Current == nil {
return errs.ErrSMTPServerNotEnabled
}
toAddress := c.String("to")
err = mail.Container.Current.SendMail(&mail.MailMessage{
To: toAddress,
Subject: "ezBookkeeping test e-mail",
Body: "This is a test e-mail",
})
if err != nil {
return err
}
fmt.Printf("Test e-mail has been sent")
return nil
}
func printRequestIdInfo(requestId string, requestIdInfo *requestid.RequestIdInfo, newRequestIdInfo *requestid.RequestIdInfo) {
fmt.Printf("[RequestId] %s\n", requestId)
fmt.Printf("[ServerUniqId] %d (Current Server %d)\n", requestIdInfo.ServerUniqId, newRequestIdInfo.ServerUniqId)
fmt.Printf("[InstanceUniqId] %d (Current Server %d)\n", requestIdInfo.InstanceUniqId, newRequestIdInfo.InstanceUniqId)
displayTime, err := utils.ParseFromElapsedSeconds(int(requestIdInfo.SecondsElapsedToday))
if err == nil {
fmt.Printf("[SecondsElapsedToday] %d (%s)\n", requestIdInfo.SecondsElapsedToday, displayTime)
} else {
fmt.Printf("[SecondsElapsedToday] %d\n", requestIdInfo.SecondsElapsedToday)
}
fmt.Printf("[RequestSeqId] %d\n", requestIdInfo.RequestSeqId)
fmt.Printf("[IsClientIpv6] %t\n", requestIdInfo.IsClientIpv6)
if requestIdInfo.IsClientIpv6 {
fmt.Printf("[ClientIpv6Hash] %d\n", requestIdInfo.ClientIp)
} else {
ip := make(net.IP, 4)
binary.BigEndian.PutUint32(ip, requestIdInfo.ClientIp)
fmt.Printf("[ClientIpv4] %s\n", ip.String())
}
fmt.Printf("[ClientPort] %d\n", requestIdInfo.ClientPort)
}
+242 -46
View File
@@ -3,15 +3,19 @@ package cmd
import (
"fmt"
"path/filepath"
"time"
"github.com/gin-contrib/cache"
"github.com/gin-contrib/cache/persistence"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/pkg/api"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/cron"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/middlewares"
@@ -25,37 +29,44 @@ import (
var WebServer = &cli.Command{
Name: "server",
Usage: "ezBookkeeping web server operation",
Subcommands: []*cli.Command{
Commands: []*cli.Command{
{
Name: "run",
Usage: "Run ezBookkeeping web server",
Action: startWebServer,
Action: bindAction(startWebServer),
},
},
}
func startWebServer(c *cli.Context) error {
func startWebServer(c *core.CliContext) error {
config, err := initializeSystem(c)
if err != nil {
return err
}
log.BootInfof("[server.startWebServer] static root path is %s", config.StaticRootPath)
log.BootInfof(c, "[webserver.startWebServer] static root path is %s", config.StaticRootPath)
if config.AutoUpdateDatabase {
err = updateAllDatabaseTablesStructure()
err = updateAllDatabaseTablesStructure(c)
if err != nil {
log.BootErrorf("[server.startWebServer] update database table structure failed, because %s", err.Error())
log.BootErrorf(c, "[webserver.startWebServer] update database table structure failed, because %s", err.Error())
return err
}
}
err = requestid.InitializeRequestIdGenerator(config)
err = requestid.InitializeRequestIdGenerator(c, config)
if err != nil {
log.BootErrorf("[server.startWebServer] initializes requestid generator failed, because %s", err.Error())
log.BootErrorf(c, "[webserver.startWebServer] initializes requestid generator failed, because %s", err.Error())
return err
}
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
if err != nil {
log.BootErrorf(c, "[webserver.startWebServer] initializes cron job scheduler failed, because %s", err.Error())
return err
}
@@ -65,12 +76,14 @@ func startWebServer(c *cli.Context) error {
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
}
log.BootInfof("[server.startWebServer] %s%s", serverInfo, uuidServerInfo)
log.BootInfof(c, "[webserver.startWebServer] %s%s", serverInfo, uuidServerInfo)
if config.Mode == settings.MODE_PRODUCTION {
gin.SetMode(gin.ReleaseMode)
}
workboxFileNames := utils.ListFileNamesWithPrefixAndSuffix(config.StaticRootPath, "workbox-", ".js")
router := gin.New()
router.Use(bindMiddleware(middlewares.Recovery))
@@ -84,11 +97,14 @@ func startWebServer(c *cli.Context) error {
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
}
router.NoRoute(bindApi(api.Default.ApiNotFound))
router.NoMethod(bindApi(api.Default.MethodNotAllowed))
serverSettingsCacheStore := persistence.NewInMemoryStore(time.Minute)
router.StaticFile("/", filepath.Join(config.StaticRootPath, "index.html"))
router.Static("/js", filepath.Join(config.StaticRootPath, "js"))
router.Static("/css", filepath.Join(config.StaticRootPath, "css"))
@@ -100,12 +116,14 @@ func startWebServer(c *cli.Context) error {
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
router.StaticFile("sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
mobileEntryRoute := router.Group("/mobile")
mobileEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
{
mobileEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "mobile.html"))
for i := 0; i < len(workboxFileNames); i++ {
router.StaticFile("/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
router.Static("/mobile/img", filepath.Join(config.StaticRootPath, "img"))
@@ -115,17 +133,13 @@ func startWebServer(c *cli.Context) error {
router.StaticFile("/mobile/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
router.StaticFile("/mobile/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
router.StaticFile("/mobile/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
router.GET("/mobile/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
workboxFileNames := utils.ListFileNamesWithPrefixAndSuffix(config.StaticRootPath, "workbox-", ".js")
for i := 0; i < len(workboxFileNames); i++ {
router.StaticFile("/mobile/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
desktopEntryRoute := router.Group("/desktop")
desktopEntryRoute.Use(bindMiddleware(middlewares.ServerSettingsCookie(config)))
{
desktopEntryRoute.StaticFile("/", filepath.Join(config.StaticRootPath, "desktop.html"))
}
router.StaticFile("/desktop", filepath.Join(config.StaticRootPath, "desktop.html"))
router.Static("/desktop/js", filepath.Join(config.StaticRootPath, "js"))
router.Static("/desktop/css", filepath.Join(config.StaticRootPath, "css"))
router.Static("/desktop/img", filepath.Join(config.StaticRootPath, "img"))
@@ -134,45 +148,110 @@ func startWebServer(c *cli.Context) error {
router.StaticFile("/desktop/favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
router.StaticFile("/desktop/touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
router.StaticFile("/desktop/manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
router.StaticFile("/desktop/sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
router.GET("/desktop/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
for i := 0; i < len(workboxFileNames); i++ {
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
avatarRoute := router.Group("/avatar")
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
{
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
}
}
if config.EnableTransactionPictures {
pictureRoute := router.Group("/pictures")
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
{
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
}
}
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
if config.Mode == settings.MODE_DEVELOPMENT {
devRoute := router.Group("/dev")
devRoute.GET("/cookies", bindMiddleware(middlewares.ServerSettingsCookie(config)))
}
proxyRoute := router.Group("/proxy")
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
{
if config.EnableMapDataFetchProxy {
if config.MapProvider == settings.OpenStreetMapProvider {
proxyRoute.GET("/openstreetmap/tile/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.OpenStreetMapTileImageProxyHandler))
if config.MapProvider == settings.OpenStreetMapProvider ||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
config.MapProvider == settings.OpenTopoMapProvider ||
config.MapProvider == settings.OPNVKarteMapProvider ||
config.MapProvider == settings.CyclOSMMapProvider ||
config.MapProvider == settings.CartoDBMapProvider ||
config.MapProvider == settings.TomTomMapProvider ||
config.MapProvider == settings.TianDiTuProvider ||
config.MapProvider == settings.CustomProvider {
proxyRoute.GET("/map/tile/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.MapTileImageProxyHandler))
}
if config.MapProvider == settings.TianDiTuProvider ||
(config.MapProvider == settings.CustomProvider && config.CustomMapTileServerAnnotationLayerUrl != "") {
proxyRoute.GET("/map/annotation/:zoomLevel/:coordinateX/:fileName", bindProxy(api.MapImages.MapAnnotationImageProxyHandler))
}
}
}
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
amapApiProxyRoute := router.Group("/_AMapService")
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie))
{
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
}
}
qrCodeRoute := router.Group("/qrcode")
qrCodeRoute.Use(bindMiddleware(middlewares.RequestId(config)))
{
qrCodeCacheStore := persistence.NewInMemoryStore(time.Minute)
qrCodeRoute.GET("/mobile_url.png", bindCachedImage(api.QrCodes.MobileUrlQrCodeHandler, qrCodeCacheStore))
}
apiRoute := router.Group("/api")
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
{
apiRoute.POST("/authorize.json", bindApi(api.Authorizations.AuthorizeHandler))
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
if config.EnableTwoFactor {
twoFactorRoute := apiRoute.Group("/2fa")
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization))
{
twoFactorRoute.POST("/authorize.json", bindApi(api.Authorizations.TwoFactorAuthorizeHandler))
twoFactorRoute.POST("/recovery.json", bindApi(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler))
twoFactorRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeHandler, config))
twoFactorRoute.POST("/recovery.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler, config))
}
}
if config.EnableUserRegister {
apiRoute.POST("/register.json", bindApi(api.Users.UserRegisterHandler))
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
}
apiRoute.GET("/logout.json", bindApi(api.Tokens.TokenRevokeCurrentHandler))
if config.EnableUserVerifyEmail {
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
emailVerifyRoute := apiRoute.Group("/verify_email")
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization))
{
emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler))
}
}
if config.EnableUserForgetPassword {
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization))
{
resetPasswordRoute.POST("/by_token.json", bindApi(api.ForgetPasswords.UserResetPasswordHandler))
}
}
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
apiV1Route := apiRoute.Group("/v1")
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization))
@@ -181,17 +260,26 @@ func startWebServer(c *cli.Context) error {
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
apiV1Route.POST("/tokens/revoke.json", bindApi(api.Tokens.TokenRevokeHandler))
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
apiV1Route.POST("/tokens/refresh.json", bindApi(api.Tokens.TokenRefreshHandler))
apiV1Route.POST("/tokens/refresh.json", bindApiWithTokenUpdate(api.Tokens.TokenRefreshHandler, config))
// Users
apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApi(api.Users.UserUpdateProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config))
// Two Factor Authorization
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
apiV1Route.POST("/users/avatar/update.json", bindApi(api.Users.UserUpdateAvatarHandler))
apiV1Route.POST("/users/avatar/remove.json", bindApi(api.Users.UserRemoveAvatarHandler))
}
if config.EnableUserVerifyEmail {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
}
// Two-Factor Authorization
if config.EnableTwoFactor {
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))
apiV1Route.POST("/users/2fa/enable/request.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableRequestHandler))
apiV1Route.POST("/users/2fa/enable/confirm.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler))
apiV1Route.POST("/users/2fa/enable/confirm.json", bindApiWithTokenUpdate(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler, config))
apiV1Route.POST("/users/2fa/disable.json", bindApi(api.TwoFactorAuthorizations.TwoFactorDisableHandler))
apiV1Route.POST("/users/2fa/recovery/regenerate.json", bindApi(api.TwoFactorAuthorizations.TwoFactorRecoveryCodeRegenerateHandler))
}
@@ -201,7 +289,8 @@ func startWebServer(c *cli.Context) error {
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler))
if config.EnableDataExport {
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataHandler))
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
apiV1Route.GET("/data/export.tsv", bindTsv(api.DataManagements.ExportDataToEzbookkeepingTSVHandler))
}
// Accounts
@@ -212,19 +301,33 @@ func startWebServer(c *cli.Context) error {
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
apiV1Route.POST("/accounts/sub_account/delete.json", bindApi(api.Accounts.SubAccountDeleteHandler))
// Transactions
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
apiV1Route.GET("/transactions/amounts/by_month.json", bindApi(api.Transactions.TransactionMonthAmountsHandler))
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
}
// Transaction Pictures
if config.EnableTransactionPictures {
apiV1Route.POST("/transaction/pictures/upload.json", bindApi(api.TransactionPictures.TransactionPictureUploadHandler))
apiV1Route.POST("/transaction/pictures/remove_unused.json", bindApi(api.TransactionPictures.TransactionPictureRemoveUnusedHandler))
}
// Transaction Categories
apiV1Route.GET("/transaction/categories/list.json", bindApi(api.TransactionCategories.CategoryListHandler))
apiV1Route.GET("/transaction/categories/get.json", bindApi(api.TransactionCategories.CategoryGetHandler))
@@ -239,33 +342,45 @@ func startWebServer(c *cli.Context) error {
apiV1Route.GET("/transaction/tags/list.json", bindApi(api.TransactionTags.TagListHandler))
apiV1Route.GET("/transaction/tags/get.json", bindApi(api.TransactionTags.TagGetHandler))
apiV1Route.POST("/transaction/tags/add.json", bindApi(api.TransactionTags.TagCreateHandler))
apiV1Route.POST("/transaction/tags/add_batch.json", bindApi(api.TransactionTags.TagCreateBatchHandler))
apiV1Route.POST("/transaction/tags/modify.json", bindApi(api.TransactionTags.TagModifyHandler))
apiV1Route.POST("/transaction/tags/hide.json", bindApi(api.TransactionTags.TagHideHandler))
apiV1Route.POST("/transaction/tags/move.json", bindApi(api.TransactionTags.TagMoveHandler))
apiV1Route.POST("/transaction/tags/delete.json", bindApi(api.TransactionTags.TagDeleteHandler))
// Transaction Templates
apiV1Route.GET("/transaction/templates/list.json", bindApi(api.TransactionTemplates.TemplateListHandler))
apiV1Route.GET("/transaction/templates/get.json", bindApi(api.TransactionTemplates.TemplateGetHandler))
apiV1Route.POST("/transaction/templates/add.json", bindApi(api.TransactionTemplates.TemplateCreateHandler))
apiV1Route.POST("/transaction/templates/modify.json", bindApi(api.TransactionTemplates.TemplateModifyHandler))
apiV1Route.POST("/transaction/templates/hide.json", bindApi(api.TransactionTemplates.TemplateHideHandler))
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
// Exchange Rates
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
apiV1Route.POST("/exchange_rates/user_custom/delete.json", bindApi(api.ExchangeRates.UserCustomExchangeRateDeleteHandler))
}
}
listenAddr := fmt.Sprintf("%s:%d", config.HttpAddr, config.HttpPort)
if config.Protocol == settings.SCHEME_SOCKET {
log.BootInfof("[server.startWebServer] will run at socks:%s", config.UnixSocketPath)
log.BootInfof(c, "[webserver.startWebServer] will run at socks:%s", config.UnixSocketPath)
err = router.RunUnix(config.UnixSocketPath)
} else if config.Protocol == settings.SCHEME_HTTP {
log.BootInfof("[server.startWebServer] will run at http://%s", listenAddr)
log.BootInfof(c, "[webserver.startWebServer] will run at http://%s", listenAddr)
err = router.Run(listenAddr)
} else if config.Protocol == settings.SCHEME_HTTPS {
log.BootInfof("[server.startWebServer] will run at https://%s", listenAddr)
log.BootInfof(c, "[webserver.startWebServer] will run at https://%s", listenAddr)
err = router.RunTLS(listenAddr, config.CertFile, config.CertKeyFile)
} else {
err = errs.ErrInvalidProtocol
}
if err != nil {
log.BootErrorf("[server.startWebServer] cannot start, because %s", err)
log.BootErrorf(c, "[webserver.startWebServer] cannot start, because %s", err)
return err
}
@@ -274,13 +389,13 @@ func startWebServer(c *cli.Context) error {
func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
fn(core.WrapContext(c))
fn(core.WrapWebContext(c))
}
}
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, err := fn(c)
if err != nil {
@@ -291,9 +406,51 @@ func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
}
}
func bindApiWithTokenUpdate(fn core.ApiHandlerFunc, config *settings.Config) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, err := fn(c)
if err == nil && config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
middlewares.AmapApiProxyAuthCookie(c, config)
}
if err != nil {
utils.PrintJsonErrorResult(c, err)
} else {
utils.PrintJsonSuccessResult(c, result)
}
}
}
func bindEventStreamApi(fn core.EventStreamApiHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
utils.SetEventStreamHeader(c)
err := fn(c)
if err != nil {
utils.WriteEventStreamJsonErrorResult(c, err)
}
}
}
func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, _, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/javascript", err)
} else {
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
}
})
}
func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
result, fileName, err := fn(c)
if err != nil {
@@ -304,9 +461,48 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
}
}
func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, fileName, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result)
}
}
}
func bindImage(fn core.ImageHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, contentType, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, contentType, "", result)
}
}
}
func bindCachedImage(fn core.ImageHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
c := core.WrapWebContext(ginCtx)
result, contentType, err := fn(c)
if err != nil {
utils.PrintDataErrorResult(c, "text/text", err)
} else {
utils.PrintDataSuccessResult(c, contentType, "", result)
}
})
}
func bindProxy(fn core.ProxyHandlerFunc) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx)
c := core.WrapWebContext(ginCtx)
proxy, err := fn(c)
if err != nil {
+263 -17
View File
@@ -25,7 +25,7 @@ root_url = %(protocol)s://%(domain)s:%(http_port)s/
cert_file =
cert_key_file =
# Unix socket path, for "socket" only
# Unix socket path, for "socket" protocol only
unix_socket =
# Static file root path (relative or absolute path)
@@ -47,10 +47,10 @@ name = ezbookkeeping
user = root
passwd =
# For "postgres" only, Either "disable", "require" or "verify-full"
# For "postgres" database only, Either "disable", "require" or "verify-full"
ssl_mode = disable
# For "sqlite3" only, db file path (relative or absolute path)
# For "sqlite3" database only, database file path (relative or absolute path)
db_path = data/ezbookkeeping.db
# Max idle connection number (0 - 65535, 0 means no idle connections are retained), default is 2
@@ -68,6 +68,19 @@ log_query = false
# Set to true to automatically update database structure when starting web server
auto_update_database = true
[mail]
# Set to true to enable sending mail by SMTP server
enable_smtp = false
# SMTP Server connection configuration
smtp_host = 127.0.0.1:25
smtp_user =
smtp_passwd =
smtp_skip_tls_verify = false
# Mail from address. This can be just an email address, or the "Name" <user@domain.com> format.
from_address =
[log]
# Either "console", "file", default is "console"
# Use space to separate multiple modes, e.g. "console file"
@@ -76,29 +89,103 @@ mode = console file
# Either "debug", "info", "warn", "error", default is "info"
level = info
# For "file" only, log file path (relative or absolute path)
# For "file" mode only, log file path (relative or absolute path)
log_path = log/ezbookkeeping.log
# For "file" only, request log file path (relative or absolute path). Leave blank if you want to write request log in default log file
request_log_path =
# For "file" only, query log file path (relative or absolute path). Leave blank if you want to write query log in default log file
query_log_path =
# For "file" only, whether rotate the log files
log_file_rotate = false
# For "file" only, maximum size (1 - 4294967295 bytes) of the log file before it gets rotated
log_file_max_size = 104857600
# For "file" only, maximum number of days to retain old log files. Set to 0 to retain all logs
log_file_max_days = 7
[storage]
# Object storage type, supports "local_filesystem" and "minio" currently
type = local_filesystem
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
local_filesystem_path = storage/
# For "minio" storage only, the minio connection configuration
minio_endpoint = 127.0.0.1:9000
minio_location =
minio_access_key_id =
minio_secret_access_key =
# For "minio" storage only, whether enable ssl for minio connection
minio_use_ssl = false
# For "minio" storage only, set to true to skip tls verification when connect minio
minio_skip_tls_verify = false
# For "minio" storage only, the minio bucket
minio_bucket = ezbookkeeping
# For "minio" storage only, the root path to store files in minio
minio_root_path = /
[uuid]
# Uuid generator type, supports "internal" currently
generator_type = internal
# For "internal" only, each server must have unique id (0 - 255)
# For "internal" uuid generator only, each server must have unique id (0 - 255)
server_id = 0
[duplicate_checker]
# Duplicate checker type, supports "in_memory" currently
checker_type = in_memory
# For "in_memory" duplicate checker only, cleanup expired data interval seconds (1 - 4294967295), default is 60 (1 minutes)
cleanup_interval = 60
# The minimum interval seconds (0 - 4294967295) between duplicate submissions on the same page (exiting and re-entering the edit page / edit dialog is considered as a new session)
# Set to 0 to disable duplicate checker for new data submissions, default is 300 (5 minutes)
duplicate_submissions_interval = 300
[cron]
# Set to true to clean up expired tokens periodically
enable_remove_expired_tokens = true
# Set to true to create scheduled transactions based on the user's templates
enable_create_scheduled_transaction = true
[security]
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
secret_key =
# Set to true to enable two factor authorization
# Set to true to enable two-factor authorization
enable_two_factor = true
# Token expired seconds (0 - 4294967295), default is 2592000 (30 days)
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
token_expired_time = 2592000
# Temporary token expired seconds (0 - 4294967295), default is 300 (5 minutes)
# Token minimum refresh interval (0 - 4294967295), the value should be less than token expired time
# Set to 0 to refresh the token every time when refreshing the front end, default is 86400 (1 day)
token_min_refresh_interval = 86400
# Temporary token expired seconds (60 - 4294967295), default is 300 (5 minutes)
temporary_token_expired_time = 300
# Email verify token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
email_verify_token_expired_time = 3600
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
password_reset_token_expired_time = 3600
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_ip_per_minute = 5
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_user_per_minute = 5
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
@@ -106,26 +193,185 @@ request_id_header = true
# Set to true to allow users to register account by themselves
enable_register = true
# Set to true to allow users to verify email address
enable_email_verify = false
# Set to true to require email must be verified when login
enable_force_email_verify = false
# Set to true to allow users to reset password
enable_forget_password = true
# Set to true to require email must be verified when use forget password
forget_password_require_email_verify = false
# Set to true to allow users to upload transaction pictures
enable_transaction_picture = true
# Maximum allowed transaction picture file size (1 - 4294967295 bytes)
max_transaction_picture_size = 10485760
# Set to true to allow users to create scheduled transaction
enable_scheduled_transaction = true
# User avatar provider, supports the following types:
# "internal": Use the internal object storage to store user avatar (refer to "storage" settings), supports updating avatar by user self
# "gravatar": https://gravatar.com
# Leave blank if you want to disable user avatar
avatar_provider = internal
# For "internal" avatar provider only, maximum allowed user avatar file size (1 - 4294967295 bytes)
max_user_avatar_size = 1048576
# The default feature restrictions after user registration (feature types separated by commas), leave blank for no restrictions
# Supports the following feature types:
# 1: Update Password
# 2: Update Email
# 3: Update Profile Basic Info
# 4: Update Avatar
# 5: Logout Other Session
# 6: Enable Two-Factor Authentication
# 7: Disable Enable Two-Factor Authentication
# 8: Forget Password
# 9: Import Transactions
# 10: Export Transactions
# 11: Clear All Data
default_feature_restrictions =
[data]
# Set to true to allow users to export their data
enable_export = true
# Set to true to allow users to import their data
enable_import = true
# Maximum allowed import file size (1 - 4294967295 bytes)
max_import_file_size = 10485760
[tip]
# Set to true to display custom tips in login page
enable_tips_in_login_page = false
# The custom tips displayed in login page, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
# For example, login_page_tips_content_zh_hans means the notification content in Chinese (Simplified)
login_page_tips_content =
[notification]
# Set to true to display custom notification in home page every time users register
enable_notification_after_register = false
# The notification content displayed each time users register, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
# For example, after_login_notification_content_zh_hans means the notification content in Chinese (Simplified)
after_register_notification_content =
# Set to true to display custom notification in home page every time users login
enable_notification_after_login = false
# The notification content displayed each time users log in, it supports multi-language configuration
after_login_notification_content =
# Set to true to display custom notification in home page every time users open the app
enable_notification_after_open = false
# The notification content displayed each time users open the app, it supports multi-language configuration
after_open_notification_content =
[map]
# Map provider, supports `openstreetmap`
# Map provider, supports the following types:
# "openstreetmap": https://www.openstreetmap.org
# "openstreetmap_humanitarian": http://map.hotosm.org
# "opentopomap": https://opentopomap.org
# "opnvkarte": https://publictransportmap.org
# "cyclosm": https://www.cyclosm.org
# "cartodb": https://carto.com/basemaps
# "tomtom": https://www.tomtom.com
# "tianditu": https://www.tianditu.gov.cn
# "googlemap": https://map.google.com
# "baidumap": https://map.baidu.com
# "amap": https://amap.com
# "custom": custom map tile server url
# Leave blank if you want to disable map
map_provider = openstreetmap
# Set to true to use the ezbookkeeping server to proxy map data requests, for "openstreetmap"
# Set to true to use the ezbookkeeping server to forward map data requests, for "openstreetmap", "openstreetmap_humanitarian", "opentopomap", "opnvkarte", "cyclosm", "cartodb", "tomtom", "tianditu" or "custom"
map_data_fetch_proxy = false
# Proxy for ezbookkeeping server requesting original map data when map_data_fetch_proxy is set to true, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
proxy = system
# For "tomtom" map provider only, TomTom map API key, please visit https://developer.tomtom.com/how-to-get-tomtom-api-key for more information
tomtom_map_api_key =
# For "tianditu" map provider only, TianDiTu map application key, please visit https://console.tianditu.gov.cn/api/register for more information
tianditu_map_app_key =
# For "googlemap" map provider only, Google map JavaScript API key, please visit https://developers.google.com/maps/get-started for more information
google_map_api_key =
# For "baidumap" map provider only, Baidu map JavaScript API application key, please visit https://lbsyun.baidu.com/index.php?title=jspopular3.0/guide/getkey for more information
baidu_map_ak =
# For "amap" map provider only, Amap JavaScript API application key, please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
amap_application_key =
# For "amap" map provider only, Amap JavaScript API security verification method, supports the following methods:
# "internal_proxy": use the internal proxy to request amap api with amap application secret (default)
# "external_proxy": use an external proxy to request amap api (amap application secret should be set by external proxy)
# "plain_text": append amap application secret to frontend request directly (insecurity for public network)
# Please visit https://developer.amap.com/api/jsapi-v2/guide/abc/load for more information
amap_security_verification_method = internal_proxy
# For "amap" map provider only, Amap JavaScript API application secret, this setting must be provided when "amap_security_verification_method" is set to "internal_proxy" or "plain_text", please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
amap_application_secret =
# For "amap" map provider only, Amap JavaScript API external proxy url, this setting must be provided when "amap_security_verification_method" is set to "external_proxy"
amap_api_external_proxy_url =
# For "custom" map provider only, the tile layer url of custom map tile server, supports {x}, {y} (coordinates) and {z} (zoom level) placeholders, like "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
custom_map_tile_server_url =
# For "custom" map provider only, the optional annotation layer url of custom map tile server, supports {x}, {y} (coordinates) and {z} (zoom level) placeholders
custom_map_tile_server_annotation_url =
# For "custom" map provider only, the min zoom level (0 - 255) for custom map tile server, default is 1
custom_map_tile_server_min_zoom_level = 1
# For "custom" map provider only, the max zoom level (0 - 255) for custom map tile server, default is 18
custom_map_tile_server_max_zoom_level = 18
# For "custom" map provider only, the default zoom level (0 - 255) for custom map tile server, default is 14
custom_map_tile_server_default_zoom_level = 14
[exchange_rates]
# Exchange rates data source, supports the following types:
# "euro_central_bank"
# "bank_of_canada"
# "reserve_bank_of_australia",
# "czech_national_bank"
# "national_bank_of_poland"
# "monetary_authority_of_singapore"
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
# "euro_central_bank": https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
# "national_bank_of_georgia": https://nbg.gov.ge/en/monetary-policy/currency
# "central_bank_of_hungary": https://www.mnb.hu/en/arfolyamok
# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
# "norges_bank": https://www.norges-bank.no/en/topics/Statistics/exchange_rates/
# "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/
# "national_bank_of_romania": https://www.bnr.ro/Exchange-rates-1224.aspx
# "bank_of_russia": https://www.cbr.ru/eng/currency_base/daily/
# "swiss_national_bank": https://www.snb.ch/en/the-snb/mandates-goals/statistics/statistics-pub/current_interest_exchange_rates
# "national_bank_of_ukraine": https://bank.gov.ua/ua/markets/exchangerates
# "central_bank_of_uzbekistan": https://cbu.uz/en/arkhiv-kursov-valyut/
# "international_monetary_fund": https://www.imf.org/external/np/fin/data/param_rms_mth.aspx
# "user_custom": users set their own exchange rates data in the UI
data_source = euro_central_bank
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds), default is 10000 (10 seconds)
# Requesting exchange rates data timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting exchange rates data, default is 10000 (10 seconds)
request_timeout = 10000
# Proxy for ezbookkeeping server requesting exchange rates data, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
proxy = system
# Set to true to skip tls verification when request exchange rates data
skip_tls_verify = false
+31
View File
@@ -0,0 +1,31 @@
import pluginVue from 'eslint-plugin-vue';
import vueTsEslintConfig from '@vue/eslint-config-typescript';
export default [
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
}
},
},
{
ignores: [
'dist/**',
'**/*.{js,jsx,cjs,mjs}'
]
},
{
files: [
'**/*.{vue,ts,tsx,mts,js,jsx,cjs,mjs}'
],
rules: {
'vue/valid-v-slot': ['error', {
allowModifiers: true
}]
}
},
];
+10 -3
View File
@@ -1,12 +1,13 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"github.com/mayswind/ezbookkeeping/cmd"
"github.com/mayswind/ezbookkeeping/pkg/settings"
@@ -28,7 +29,7 @@ func main() {
settings.Version = Version
settings.CommitHash = CommitHash
app := &cli.App{
cmd := &cli.Command{
Name: "ezBookkeeping",
Usage: "A lightweight personal bookkeeping app hosted by yourself.",
Version: GetFullVersion(),
@@ -36,17 +37,23 @@ func main() {
cmd.WebServer,
cmd.Database,
cmd.UserData,
cmd.CronJobs,
cmd.SecurityUtils,
cmd.Utilities,
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "conf-path",
Usage: "Custom config `FILE` path",
},
&cli.BoolFlag{
Name: "no-boot-log",
Usage: "Disable boot log",
},
},
}
err := app.Run(os.Args)
err := cmd.Run(context.Background(), os.Args)
if err != nil {
log.Fatalf("Failed to run ezBookkeeping with %s: %v", os.Args, err)
+70 -31
View File
@@ -1,54 +1,93 @@
module github.com/mayswind/ezbookkeeping
go 1.20
go 1.24
require (
github.com/gin-contrib/gzip v0.0.6
github.com/gin-gonic/gin v1.9.0
github.com/go-playground/validator/v10 v10.14.0
github.com/go-sql-driver/mysql v1.7.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/boombuler/barcode v1.0.2
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
github.com/gin-contrib/cache v1.3.2
github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.16.1
github.com/go-playground/validator/v10 v10.26.0
github.com/go-sql-driver/mysql v1.9.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-sqlite3 v1.14.28
github.com/minio/minio-go/v7 v7.0.91
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.4.0
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.2
github.com/urfave/cli/v2 v2.25.4
golang.org/x/crypto v0.9.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.2.0
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
golang.org/x/text v0.24.0
gopkg.in/ini.v1 v1.67.0
xorm.io/xorm v1.3.2
gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.9
)
require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.8.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
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/minio/crc64nvme v1.0.1 // 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
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/tealeg/xlsx v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.9 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
xorm.io/builder v0.3.12 // indirect
)
+144 -645
View File
@@ -1,713 +1,212 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a h1:c5k29baTzznteWs+9dxrtqpNxgtQ3V5NbU8d6laLK9Q=
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a/go.mod h1:xbpgo9r3xURoPa/l3sLKLGcnWlkz9UkfFsQ7lW0S6h8=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/cache v1.3.2 h1:MsMTuG4KMhD2SVq5ygSYRci3BYdb/Egvk8lLNIB53gM=
github.com/gin-contrib/cache v1.3.2/go.mod h1:lnZv6QsBGSiqyB3rbNO2uVMWDBcMiZtHqH3Jlk57vaE=
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo=
github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.8.1/go.mod h1:JV6m6b6jhjdmzchES0drzCcYcAHS1OPD5xu3OZ/lE2g=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI75BE=
github.com/jackc/pgtype v1.8.0/go.mod h1:PqDKcEBtllAtk/2p6z6SHdXW5UB+MhE75tUol2OKexE=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CIdYfcc=
github.com/jackc/pgx/v4 v4.12.0/go.mod h1:fE547h6VulLPA3kySjfnSG/e2D861g/50JlVUa/ub60=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/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/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
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.91 h1:tWLZnEfo3OZl5PoXQwcwTAPNNrjyWwOh6cbZitW5JQc=
github.com/minio/minio-go/v7 v7.0.91/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
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=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.25.4 h1:HyYwPrTO3im9rYhUff/ZNs78eolxt0nJ4LN+9yJKSH4=
github.com/urfave/cli/v2 v2.25.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v3 v3.2.0 h1:m8WIXY0U9LCuUl5r+0fqLWDhNYWt6qvlW+GcF4EoXf8=
github.com/urfave/cli/v3 v3.2.0/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.4/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.5/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.7/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.8/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.10/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.15/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.16/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.17/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.35.18/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60=
modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw=
modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI=
modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag=
modernc.org/ccgo/v3 v3.11.3/go.mod h1:0oHunRBMBiXOKdaglfMlRPBALQqsfrCKXgw9okQ3GEw=
modernc.org/ccgo/v3 v3.12.4/go.mod h1:Bk+m6m2tsooJchP/Yk5ji56cClmN6R1cqc9o/YtbgBQ=
modernc.org/ccgo/v3 v3.12.6/go.mod h1:0Ji3ruvpFPpz+yu+1m0wk68pdr/LENABhTrDkMDWH6c=
modernc.org/ccgo/v3 v3.12.8/go.mod h1:Hq9keM4ZfjCDuDXxaHptpv9N24JhgBZmUG5q60iLgUo=
modernc.org/ccgo/v3 v3.12.11/go.mod h1:0jVcmyDwDKDGWbcrzQ+xwJjbhZruHtouiBEvDfoIsdg=
modernc.org/ccgo/v3 v3.12.14/go.mod h1:GhTu1k0YCpJSuWwtRAEHAol5W7g1/RRfS4/9hc9vF5I=
modernc.org/ccgo/v3 v3.12.18/go.mod h1:jvg/xVdWWmZACSgOiAhpWpwHWylbJaSzayCqNOJKIhs=
modernc.org/ccgo/v3 v3.12.20/go.mod h1:aKEdssiu7gVgSy/jjMastnv/q6wWGRbszbheXgWRHc8=
modernc.org/ccgo/v3 v3.12.21/go.mod h1:ydgg2tEprnyMn159ZO/N4pLBqpL7NOkJ88GT5zNU2dE=
modernc.org/ccgo/v3 v3.12.22/go.mod h1:nyDVFMmMWhMsgQw+5JH6B6o4MnZ+UQNw1pp52XYFPRk=
modernc.org/ccgo/v3 v3.12.25/go.mod h1:UaLyWI26TwyIT4+ZFNjkyTbsPsY3plAEB6E7L/vZV3w=
modernc.org/ccgo/v3 v3.12.29/go.mod h1:FXVjG7YLf9FetsS2OOYcwNhcdOLGt8S9bQ48+OP75cE=
modernc.org/ccgo/v3 v3.12.36/go.mod h1:uP3/Fiezp/Ga8onfvMLpREq+KUjUmYMxXPO8tETHtA8=
modernc.org/ccgo/v3 v3.12.38/go.mod h1:93O0G7baRST1vNj4wnZ49b1kLxt0xCW5Hsa2qRaZPqc=
modernc.org/ccgo/v3 v3.12.43/go.mod h1:k+DqGXd3o7W+inNujK15S5ZYuPoWYLpF5PYougCmthU=
modernc.org/ccgo/v3 v3.12.46/go.mod h1:UZe6EvMSqOxaJ4sznY7b23/k13R8XNlyWsO5bAmSgOE=
modernc.org/ccgo/v3 v3.12.47/go.mod h1:m8d6p0zNps187fhBwzY/ii6gxfjob1VxWb919Nk1HUk=
modernc.org/ccgo/v3 v3.12.50/go.mod h1:bu9YIwtg+HXQxBhsRDE+cJjQRuINuT9PUK4orOco/JI=
modernc.org/ccgo/v3 v3.12.51/go.mod h1:gaIIlx4YpmGO2bLye04/yeblmvWEmE4BBBls4aJXFiE=
modernc.org/ccgo/v3 v3.12.53/go.mod h1:8xWGGTFkdFEWBEsUmi+DBjwu/WLy3SSOrqEmKUjMeEg=
modernc.org/ccgo/v3 v3.12.54/go.mod h1:yANKFTm9llTFVX1FqNKHE0aMcQb1fuPJx6p8AcUx+74=
modernc.org/ccgo/v3 v3.12.55/go.mod h1:rsXiIyJi9psOwiBkplOaHye5L4MOOaCjHg1Fxkj7IeU=
modernc.org/ccgo/v3 v3.12.56/go.mod h1:ljeFks3faDseCkr60JMpeDb2GSO3TKAmrzm7q9YOcMU=
modernc.org/ccgo/v3 v3.12.57/go.mod h1:hNSF4DNVgBl8wYHpMvPqQWDQx8luqxDnNGCMM4NFNMc=
modernc.org/ccgo/v3 v3.12.60/go.mod h1:k/Nn0zdO1xHVWjPYVshDeWKqbRWIfif5dtsIOCUVMqM=
modernc.org/ccgo/v3 v3.12.65/go.mod h1:D6hQtKxPNZiY6wDBtehSGKFKmyXn53F8nGTpH+POmS4=
modernc.org/ccgo/v3 v3.12.66/go.mod h1:jUuxlCFZTUZLMV08s7B1ekHX5+LIAurKTTaugUr/EhQ=
modernc.org/ccgo/v3 v3.12.67/go.mod h1:Bll3KwKvGROizP2Xj17GEGOTrlvB1XcVaBrC90ORO84=
modernc.org/ccgo/v3 v3.12.73/go.mod h1:hngkB+nUUqzOf3iqsM48Gf1FZhY599qzVg1iX+BT3cQ=
modernc.org/ccgo/v3 v3.12.81/go.mod h1:p2A1duHoBBg1mFtYvnhAnQyI6vL0uw5PGYLSIgF6rYY=
modernc.org/ccgo/v3 v3.12.82/go.mod h1:ApbflUfa5BKadjHynCficldU1ghjen84tuM5jRynB7w=
modernc.org/ccorpus v1.11.1/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg=
modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M=
modernc.org/libc v1.11.5/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU=
modernc.org/libc v1.11.6/go.mod h1:ddqmzR6p5i4jIGK1d/EiSw97LBcE3dK24QEwCFvgNgE=
modernc.org/libc v1.11.11/go.mod h1:lXEp9QOOk4qAYOtL3BmMve99S5Owz7Qyowzvg6LiZso=
modernc.org/libc v1.11.13/go.mod h1:ZYawJWlXIzXy2Pzghaf7YfM8OKacP3eZQI81PDLFdY8=
modernc.org/libc v1.11.16/go.mod h1:+DJquzYi+DMRUtWI1YNxrlQO6TcA5+dRRiq8HWBWRC8=
modernc.org/libc v1.11.19/go.mod h1:e0dgEame6mkydy19KKaVPBeEnyJB4LGNb0bBH1EtQ3I=
modernc.org/libc v1.11.24/go.mod h1:FOSzE0UwookyT1TtCJrRkvsOrX2k38HoInhw+cSCUGk=
modernc.org/libc v1.11.26/go.mod h1:SFjnYi9OSd2W7f4ct622o/PAYqk7KHv6GS8NZULIjKY=
modernc.org/libc v1.11.27/go.mod h1:zmWm6kcFXt/jpzeCgfvUNswM0qke8qVwxqZrnddlDiE=
modernc.org/libc v1.11.28/go.mod h1:Ii4V0fTFcbq3qrv3CNn+OGHAvzqMBvC7dBNyC4vHZlg=
modernc.org/libc v1.11.31/go.mod h1:FpBncUkEAtopRNJj8aRo29qUiyx5AvAlAxzlx9GNaVM=
modernc.org/libc v1.11.34/go.mod h1:+Tzc4hnb1iaX/SKAutJmfzES6awxfU1BPvrrJO0pYLg=
modernc.org/libc v1.11.37/go.mod h1:dCQebOwoO1046yTrfUE5nX1f3YpGZQKNcITUYWlrAWo=
modernc.org/libc v1.11.39/go.mod h1:mV8lJMo2S5A31uD0k1cMu7vrJbSA3J3waQJxpV4iqx8=
modernc.org/libc v1.11.42/go.mod h1:yzrLDU+sSjLE+D4bIhS7q1L5UwXDOw99PLSX0BlZvSQ=
modernc.org/libc v1.11.44/go.mod h1:KFq33jsma7F5WXiYelU8quMJasCCTnHK0mkri4yPHgA=
modernc.org/libc v1.11.45/go.mod h1:Y192orvfVQQYFzCNsn+Xt0Hxt4DiO4USpLNXBlXg/tM=
modernc.org/libc v1.11.47/go.mod h1:tPkE4PzCTW27E6AIKIR5IwHAQKCAtudEIeAV1/SiyBg=
modernc.org/libc v1.11.49/go.mod h1:9JrJuK5WTtoTWIFQ7QjX2Mb/bagYdZdscI3xrvHbXjE=
modernc.org/libc v1.11.51/go.mod h1:R9I8u9TS+meaWLdbfQhq2kFknTW0O3aw3kEMqDDxMaM=
modernc.org/libc v1.11.53/go.mod h1:5ip5vWYPAoMulkQ5XlSJTy12Sz5U6blOQiYasilVPsU=
modernc.org/libc v1.11.54/go.mod h1:S/FVnskbzVUrjfBqlGFIPA5m7UwB3n9fojHhCNfSsnw=
modernc.org/libc v1.11.55/go.mod h1:j2A5YBRm6HjNkoSs/fzZrSxCuwWqcMYTDPLNx0URn3M=
modernc.org/libc v1.11.56/go.mod h1:pakHkg5JdMLt2OgRadpPOTnyRXm/uzu+Yyg/LSLdi18=
modernc.org/libc v1.11.58/go.mod h1:ns94Rxv0OWyoQrDqMFfWwka2BcaF6/61CqJRK9LP7S8=
modernc.org/libc v1.11.70/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw=
modernc.org/libc v1.11.71/go.mod h1:DUOmMYe+IvKi9n6Mycyx3DbjfzSKrdr/0Vgt3j7P5gw=
modernc.org/libc v1.11.75/go.mod h1:dGRVugT6edz361wmD9gk6ax1AbDSe0x5vji0dGJiPT0=
modernc.org/libc v1.11.82/go.mod h1:NF+Ek1BOl2jeC7lw3a7Jj5PWyHPwWD4aq3wVKxqV1fI=
modernc.org/libc v1.11.86/go.mod h1:ePuYgoQLmvxdNT06RpGnaDKJmDNEkV7ZPKI2jnsvZoE=
modernc.org/libc v1.11.87/go.mod h1:Qvd5iXTeLhI5PS0XSyqMY99282y+3euapQFxM7jYnpY=
modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.14.2/go.mod h1:yqfn85u8wVOE6ub5UT8VI9JjhrwBUUCNyTACN0h6Sx8=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/tcl v1.8.13/go.mod h1:V+q/Ef0IJaNUSECieLU4o+8IScapxnMyFV6i/7uQlAY=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.2.19/go.mod h1:+ZpP0pc4zz97eukOzW3xagV/lS82IpPN9NGG5pNF9vY=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/builder v0.3.12 h1:ASZYX7fQmy+o8UJdhlLHSW57JDOkM8DNhcAF5d0LiJM=
xorm.io/builder v0.3.12/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.2 h1:uTRRKF2jYzbZ5nsofXVUx6ncMaek+SHjWYtCXyZo1oM=
xorm.io/xorm v1.3.2/go.mod h1:9NbjqdnjX6eyjRRhh01GHm64r6N9shTb/8Ak3YRt8Nw=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
+6929 -3305
View File
File diff suppressed because it is too large Load Diff
+39 -22
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "0.3.0",
"version": "0.9.0",
"private": true,
"repository": {
"type": "git",
@@ -15,41 +15,58 @@
"serve": "cross-env NODE_ENV=development vite",
"build": "cross-env NODE_ENV=production vite build",
"serve:dist": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
"lint": "tsc --noEmit && eslint . --fix"
},
"dependencies": {
"@vuepic/vue-datepicker": "^5.1.2",
"axios": "^1.4.0",
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^11.0.2",
"axios": "^1.9.0",
"cbor-js": "^0.1.0",
"clipboard": "^2.0.11",
"crypto-js": "^4.1.1",
"crypto-js": "^4.2.0",
"dom7": "^4.0.6",
"framework7": "^8.0.5",
"echarts": "^5.6.0",
"framework7": "^8.3.4",
"framework7-icons": "^5.0.5",
"framework7-vue": "^8.0.5",
"js-cookie": "^3.0.5",
"framework7-vue": "^8.3.4",
"leaflet": "^1.9.4",
"line-awesome": "^1.3.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"pinia": "^3.0.2",
"register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1",
"swiper": "^9.3.2",
"ua-parser-js": "^1.0.35",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vuex": "^4.1.0"
"swiper": "^10.2.0",
"ua-parser-js": "^1.0.39",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-i18n": "^11.1.3",
"vue-router": "^4.5.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.8.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.0",
"@vue/compiler-sfc": "^3.3.4",
"@tsconfig/node22": "^22.0.1",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2",
"@types/node": "^22.15.2",
"@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"cross-env": "^7.0.3",
"eslint": "^8.41.0",
"eslint-plugin-vue": "^9.14.1",
"eslint": "^9.25.1",
"eslint-plugin-vue": "^10.0.0",
"git-rev-sync": "^3.0.2",
"postcss-preset-env": "^8.4.1",
"vite": "^4.3.5",
"vite-plugin-pwa": "^0.15.1"
"postcss-preset-env": "^10.1.6",
"sass": "^1.87.0",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-vuetify": "^2.1.1",
"vue-tsc": "^2.2.10"
},
"browserslist": [
"> 1%",
+433 -69
View File
@@ -4,40 +4,54 @@ import (
"sort"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
// AccountsApi represents account api
type AccountsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
accounts *services.AccountService
}
// Initialize an account api singleton instance
var (
Accounts = &AccountsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
accounts: services.Accounts,
}
)
// AccountListHandler returns accounts list of current user
func (a *AccountsApi) AccountListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountListHandler(c *core.WebContext) (any, *errs.Error) {
var accountListReq models.AccountListRequest
err := c.ShouldBindQuery(&accountListReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountListHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountListHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
accounts, err := a.accounts.GetAllAccountsByUid(uid)
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountListHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[accounts.AccountListHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -84,20 +98,20 @@ func (a *AccountsApi) AccountListHandler(c *core.Context) (interface{}, *errs.Er
}
// AccountGetHandler returns one specific account of current user
func (a *AccountsApi) AccountGetHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountGetHandler(c *core.WebContext) (any, *errs.Error) {
var accountGetReq models.AccountGetRequest
err := c.ShouldBindQuery(&accountGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(uid, accountGetReq.Id)
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountGetHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountGetReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountGetHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -127,38 +141,60 @@ func (a *AccountsApi) AccountGetHandler(c *core.Context) (interface{}, *errs.Err
}
// AccountCreateHandler saves a new account by request parameters for current user
func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountCreateHandler(c *core.WebContext) (any, *errs.Error) {
var accountCreateReq models.AccountCreateRequest
err := c.ShouldBindJSON(&accountCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[accounts.AccountCreateHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
if accountCreateReq.Category < models.ACCOUNT_CATEGORY_CASH || accountCreateReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
log.Warnf(c, "[accounts.AccountCreateHandler] account category invalid, category is %d", accountCreateReq.Category)
return nil, errs.ErrAccountCategoryInvalid
}
if accountCreateReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountCreateReq.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountCreateHandler] cannot set statement date with category \"%d\"", accountCreateReq.Category)
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
}
if accountCreateReq.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
if len(accountCreateReq.SubAccounts) > 0 {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account cannot have any sub accounts")
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot have any sub-accounts")
return nil, errs.ErrAccountCannotHaveSubAccounts
}
if accountCreateReq.Currency == validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
log.Warnf(c, "[accounts.AccountCreateHandler] account cannot set currency placeholder")
return nil, errs.ErrAccountCurrencyInvalid
}
if accountCreateReq.Balance != 0 && accountCreateReq.BalanceTime <= 0 {
log.Warnf(c, "[accounts.AccountCreateHandler] account balance time is not set")
return nil, errs.ErrAccountBalanceTimeNotSet
}
} else if accountCreateReq.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
if len(accountCreateReq.SubAccounts) < 1 {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account does not have any sub accounts")
log.Warnf(c, "[accounts.AccountCreateHandler] account does not have any sub-accounts")
return nil, errs.ErrAccountHaveNoSubAccount
}
if accountCreateReq.Currency != validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parent account cannot set currency")
log.Warnf(c, "[accounts.AccountCreateHandler] parent account cannot set currency")
return nil, errs.ErrParentAccountCannotSetCurrency
}
if accountCreateReq.Balance != 0 {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] parent account cannot set balance")
log.Warnf(c, "[accounts.AccountCreateHandler] parent account cannot set balance")
return nil, errs.ErrParentAccountCannotSetBalance
}
@@ -166,45 +202,92 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.
subAccount := accountCreateReq.SubAccounts[i]
if subAccount.Category != accountCreateReq.Category {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] category of sub account not equals to parent")
log.Warnf(c, "[accounts.AccountCreateHandler] category of sub-account#%d not equals to parent", i)
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
}
if subAccount.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] sub account type invalid")
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d type invalid", i)
return nil, errs.ErrSubAccountTypeInvalid
}
if subAccount.Currency == validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] sub account cannot set currency placeholder")
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set currency placeholder", i)
return nil, errs.ErrAccountCurrencyInvalid
}
if subAccount.Balance != 0 && subAccount.BalanceTime <= 0 {
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d balance time is not set", i)
return nil, errs.ErrAccountBalanceTimeNotSet
}
if subAccount.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountCreateHandler] sub-account#%d cannot set statement date", i)
return nil, errs.ErrCannotSetStatementDateForSubAccount
}
}
} else {
log.WarnfWithRequestId(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
log.Warnf(c, "[accounts.AccountCreateHandler] account type invalid, type is %d", accountCreateReq.Type)
return nil, errs.ErrAccountTypeInvalid
}
uid := c.GetCurrentUid()
maxOrderId, err := a.accounts.GetMaxDisplayOrder(uid, accountCreateReq.Category)
maxOrderId, err := a.accounts.GetMaxDisplayOrder(c, uid, accountCreateReq.Category)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[accounts.AccountCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1)
childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq)
mainAccount := a.createNewAccountModel(uid, &accountCreateReq, false, maxOrderId+1)
childrenAccounts, childrenAccountBalanceTimes := a.createSubAccountModels(uid, &accountCreateReq)
err = a.accounts.CreateAccounts(mainAccount, childrenAccounts)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId)
if found {
log.Infof(c, "[accounts.AccountCreateHandler] another account \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
accountId, err := utils.StringToInt64(remark)
if err == nil {
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
if err != nil {
log.Errorf(c, "[accounts.AccountCreateHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
mainAccount, exists := accountMap[accountId]
if !exists {
return nil, errs.ErrOperationFailed
}
accountInfoResp := mainAccount.ToAccountInfoResponse()
for i := 0; i < len(accountAndSubAccounts); i++ {
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
}
}
return accountInfoResp, nil
}
}
}
err = a.accounts.CreateAccounts(c, mainAccount, accountCreateReq.BalanceTime, childrenAccounts, childrenAccountBalanceTimes, utcOffset)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
log.Errorf(c, "[accounts.AccountCreateHandler] failed to create account \"id:%d\" for user \"uid:%d\", because %s", mainAccount.AccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
log.Infof(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId)
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
accountInfoResp := mainAccount.ToAccountInfoResponse()
if len(childrenAccounts) > 0 {
@@ -219,55 +302,182 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (interface{}, *errs.
}
// AccountModifyHandler saves an existed account by request parameters for current user
func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error) {
var accountModifyReq models.AccountModifyRequest
err := c.ShouldBindJSON(&accountModifyReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountModifyHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(uid, accountModifyReq.Id)
if accountModifyReq.Id <= 0 {
return nil, errs.ErrAccountIdInvalid
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountModifyHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
log.Warnf(c, "[accounts.AccountModifyHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
if accountModifyReq.Category < models.ACCOUNT_CATEGORY_CASH || accountModifyReq.Category > models.ACCOUNT_CATEGORY_CERTIFICATE_OF_DEPOSIT {
log.Warnf(c, "[accounts.AccountModifyHandler] account category invalid, category is %d", accountModifyReq.Category)
return nil, errs.ErrAccountCategoryInvalid
}
if accountModifyReq.Category != models.ACCOUNT_CATEGORY_CREDIT_CARD && accountModifyReq.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] cannot set statement date with category \"%d\"", accountModifyReq.Category)
return nil, errs.ErrCannotSetStatementDateForNonCreditCard
}
uid := c.GetCurrentUid()
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
if err != nil {
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
mainAccount, exists := accountMap[accountModifyReq.Id]
if _, exists := accountMap[accountModifyReq.Id]; !exists {
if !exists {
return nil, errs.ErrAccountNotFound
}
if len(accountModifyReq.SubAccounts)+1 != len(accountAndSubAccounts) {
return nil, errs.ErrCannotAddOrDeleteSubAccountsWhenModify
if accountModifyReq.Currency != nil && mainAccount.Currency != *accountModifyReq.Currency {
return nil, errs.ErrNotSupportedChangeCurrency
}
if accountModifyReq.Balance != nil {
return nil, errs.ErrNotSupportedChangeBalance
}
if accountModifyReq.BalanceTime != nil {
return nil, errs.ErrNotSupportedChangeBalanceTime
}
if mainAccount.Type == models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
if len(accountModifyReq.SubAccounts) > 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] account cannot have any sub-accounts")
return nil, errs.ErrAccountCannotHaveSubAccounts
}
} else if mainAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
if len(accountModifyReq.SubAccounts) < 1 {
log.Warnf(c, "[accounts.AccountModifyHandler] account does not have any sub-accounts")
return nil, errs.ErrAccountHaveNoSubAccount
}
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
subAccountReq := accountModifyReq.SubAccounts[i]
if subAccountReq.Category != accountModifyReq.Category {
log.Warnf(c, "[accounts.AccountModifyHandler] category of sub-account#%d not equals to parent", i)
return nil, errs.ErrSubAccountCategoryNotEqualsToParent
}
if subAccountReq.Id == 0 { // create new sub-account
if subAccountReq.Currency == nil {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d not set currency", i)
return nil, errs.ErrAccountCurrencyInvalid
} else if subAccountReq.Currency != nil && *subAccountReq.Currency == validators.ParentAccountCurrencyPlaceholder {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set currency placeholder", i)
return nil, errs.ErrAccountCurrencyInvalid
}
if subAccountReq.Balance == nil {
defaultBalance := int64(0)
subAccountReq.Balance = &defaultBalance
}
if *subAccountReq.Balance == 0 {
defaultBalanceTime := int64(0)
subAccountReq.BalanceTime = &defaultBalanceTime
}
if *subAccountReq.Balance != 0 && (subAccountReq.BalanceTime == nil || *subAccountReq.BalanceTime <= 0) {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d balance time is not set", i)
return nil, errs.ErrAccountBalanceTimeNotSet
}
} else { // modify existed sub-account
subAccount, exists := accountMap[subAccountReq.Id]
if !exists {
return nil, errs.ErrAccountNotFound
}
if subAccountReq.Currency != nil && subAccount.Currency != *subAccountReq.Currency {
return nil, errs.ErrNotSupportedChangeCurrency
}
if subAccountReq.Balance != nil {
return nil, errs.ErrNotSupportedChangeBalance
}
if subAccountReq.BalanceTime != nil {
return nil, errs.ErrNotSupportedChangeBalanceTime
}
}
if subAccountReq.CreditCardStatementDate != 0 {
log.Warnf(c, "[accounts.AccountModifyHandler] sub-account#%d cannot set statement date", i)
return nil, errs.ErrCannotSetStatementDateForSubAccount
}
}
}
anythingUpdate := false
var toUpdateAccounts []*models.Account
var toAddAccounts []*models.Account
var toAddAccountBalanceTimes []int64
var toDeleteAccountIds []int64
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, accountMap[accountModifyReq.Id])
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
if toUpdateAccount != nil {
anythingUpdate = true
toUpdateAccounts = append(toUpdateAccounts, toUpdateAccount)
}
toDeleteAccountIds = a.getToDeleteSubAccountIds(&accountModifyReq, mainAccount, accountAndSubAccounts)
if len(toDeleteAccountIds) > 0 {
anythingUpdate = true
}
maxOrderId := int32(0)
for i := 0; i < len(accountAndSubAccounts); i++ {
account := accountAndSubAccounts[i]
if account.AccountId != mainAccount.AccountId && account.DisplayOrder > maxOrderId {
maxOrderId = account.DisplayOrder
}
}
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
subAccountReq := accountModifyReq.SubAccounts[i]
if _, exists := accountMap[subAccountReq.Id]; !exists {
return nil, errs.ErrAccountNotFound
}
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id])
if toUpdateSubAccount != nil {
anythingUpdate = true
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
maxOrderId = maxOrderId + 1
newSubAccount := a.createNewSubAccountModelForModify(uid, mainAccount.Type, subAccountReq, maxOrderId)
toAddAccounts = append(toAddAccounts, newSubAccount)
if subAccountReq.BalanceTime != nil {
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, *subAccountReq.BalanceTime)
} else {
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
}
} else {
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
if toUpdateSubAccount != nil {
anythingUpdate = true
toUpdateAccounts = append(toUpdateAccounts, toUpdateSubAccount)
}
}
}
@@ -275,14 +485,54 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
return nil, errs.ErrNothingWillBeUpdated
}
err = a.accounts.ModifyAccounts(uid, toUpdateAccounts)
if len(toAddAccounts) > 0 && a.CurrentConfig().EnableDuplicateSubmissionsCheck && accountModifyReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId)
if found {
log.Infof(c, "[accounts.AccountModifyHandler] another account \"id:%s\" modification has been created for user \"uid:%d\"", remark, uid)
accountId, err := utils.StringToInt64(remark)
if err == nil {
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId)
if err != nil {
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
mainAccount, exists := accountMap[accountId]
if !exists {
return nil, errs.ErrOperationFailed
}
accountInfoResp := mainAccount.ToAccountInfoResponse()
for i := 0; i < len(accountAndSubAccounts); i++ {
if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId {
subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse()
accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp)
}
}
return accountInfoResp, nil
}
}
}
err = a.accounts.ModifyAccounts(c, mainAccount, toUpdateAccounts, toAddAccounts, toAddAccountBalanceTimes, toDeleteAccountIds, utcOffset)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountModifyHandler] failed to update account \"id:%d\" for user \"uid:%d\", because %s", accountModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
log.Infof(c, "[accounts.AccountModifyHandler] user \"uid:%d\" has updated account \"id:%d\" successfully", uid, accountModifyReq.Id)
if len(toAddAccounts) > 0 {
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_SUBACCOUNT, uid, accountModifyReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId))
}
accountRespMap := make(map[int64]*models.AccountInfoResponse)
@@ -300,11 +550,23 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
accountRespMap[accountResp.Id] = accountResp
}
for i := 0; i < len(toAddAccounts); i++ {
account := toAddAccounts[i]
accountResp := account.ToAccountInfoResponse()
accountRespMap[accountResp.Id] = accountResp
}
deletedAccountIds := make(map[int64]bool)
for i := 0; i < len(toDeleteAccountIds); i++ {
deletedAccountIds[toDeleteAccountIds[i]] = true
}
for i := 0; i < len(accountAndSubAccounts); i++ {
oldAccount := accountAndSubAccounts[i]
_, exists := accountRespMap[oldAccount.AccountId]
if !exists {
if !exists && !deletedAccountIds[oldAccount.AccountId] {
oldAccountResp := oldAccount.ToAccountInfoResponse()
accountRespMap[oldAccountResp.Id] = oldAccountResp
}
@@ -313,8 +575,19 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
accountResp := accountRespMap[accountModifyReq.Id]
for i := 0; i < len(accountAndSubAccounts); i++ {
if accountAndSubAccounts[i].ParentAccountId == accountResp.Id {
subAccountResp := accountRespMap[accountAndSubAccounts[i].AccountId]
account := accountAndSubAccounts[i]
if account.ParentAccountId == accountResp.Id && !deletedAccountIds[account.AccountId] {
subAccountResp := accountRespMap[account.AccountId]
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
}
}
for i := 0; i < len(toAddAccounts); i++ {
account := toAddAccounts[i]
if account.ParentAccountId == accountResp.Id {
subAccountResp := accountRespMap[account.AccountId]
accountResp.SubAccounts = append(accountResp.SubAccounts, subAccountResp)
}
}
@@ -325,34 +598,34 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
}
// AccountHideHandler hides an existed account by request parameters for current user
func (a *AccountsApi) AccountHideHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountHideHandler(c *core.WebContext) (any, *errs.Error) {
var accountHideReq models.AccountHideRequest
err := c.ShouldBindJSON(&accountHideReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountHideHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.accounts.HideAccount(uid, []int64{accountHideReq.Id}, accountHideReq.Hidden)
err = a.accounts.HideAccount(c, uid, []int64{accountHideReq.Id}, accountHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountHideHandler] failed to hide account \"id:%d\" for user \"uid:%d\", because %s", accountHideReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountHideHandler] failed to hide account \"id:%d\" for user \"uid:%d\", because %s", accountHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountHideHandler] user \"uid:%d\" has hidden account \"id:%d\"", uid, accountHideReq.Id)
log.Infof(c, "[accounts.AccountHideHandler] user \"uid:%d\" has hidden account \"id:%d\"", uid, accountHideReq.Id)
return true, nil
}
// AccountMoveHandler moves display order of existed accounts by request parameters for current user
func (a *AccountsApi) AccountMoveHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountMoveHandler(c *core.WebContext) (any, *errs.Error) {
var accountMoveReq models.AccountMoveRequest
err := c.ShouldBindJSON(&accountMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountMoveHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -370,40 +643,68 @@ func (a *AccountsApi) AccountMoveHandler(c *core.Context) (interface{}, *errs.Er
accounts[i] = account
}
err = a.accounts.ModifyAccountDisplayOrders(uid, accounts)
err = a.accounts.ModifyAccountDisplayOrders(c, uid, accounts)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountMoveHandler] failed to move accounts for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[accounts.AccountMoveHandler] failed to move accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountMoveHandler] user \"uid:%d\" has moved accounts", uid)
log.Infof(c, "[accounts.AccountMoveHandler] user \"uid:%d\" has moved accounts", uid)
return true, nil
}
// AccountDeleteHandler deletes an existed account by request parameters for current user
func (a *AccountsApi) AccountDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AccountsApi) AccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var accountDeleteReq models.AccountDeleteRequest
err := c.ShouldBindJSON(&accountDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[accounts.AccountDeleteHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[accounts.AccountDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.accounts.DeleteAccount(uid, accountDeleteReq.Id)
err = a.accounts.DeleteAccount(c, uid, accountDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[accounts.AccountDeleteHandler] failed to delete account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
log.Errorf(c, "[accounts.AccountDeleteHandler] failed to delete account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[accounts.AccountDeleteHandler] user \"uid:%d\" has deleted account \"id:%d\"", uid, accountDeleteReq.Id)
log.Infof(c, "[accounts.AccountDeleteHandler] user \"uid:%d\" has deleted account \"id:%d\"", uid, accountDeleteReq.Id)
return true, nil
}
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, order int32) *models.Account {
// SubAccountDeleteHandler deletes an existed sub-account by request parameters for current user
func (a *AccountsApi) SubAccountDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var accountDeleteReq models.AccountDeleteRequest
err := c.ShouldBindJSON(&accountDeleteReq)
if err != nil {
log.Warnf(c, "[accounts.SubAccountDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.accounts.DeleteSubAccount(c, uid, accountDeleteReq.Id)
if err != nil {
log.Errorf(c, "[accounts.SubAccountDeleteHandler] failed to delete sub-account \"id:%d\" for user \"uid:%d\", because %s", accountDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[accounts.SubAccountDeleteHandler] user \"uid:%d\" has deleted sub-account \"id:%d\"", uid, accountDeleteReq.Id)
return true, nil
}
func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.AccountCreateRequest, isSubAccount bool, order int32) *models.Account {
accountExtend := &models.AccountExtend{}
if !isSubAccount && accountCreateReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
accountExtend.CreditCardStatementDate = &accountCreateReq.CreditCardStatementDate
}
return &models.Account{
Uid: uid,
Name: accountCreateReq.Name,
@@ -415,24 +716,51 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
Currency: accountCreateReq.Currency,
Balance: accountCreateReq.Balance,
Comment: accountCreateReq.Comment,
Extend: accountExtend,
}
}
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) []*models.Account {
func (a *AccountsApi) createNewSubAccountModelForModify(uid int64, accountType models.AccountType, accountModifyReq *models.AccountModifyRequest, order int32) *models.Account {
accountExtend := &models.AccountExtend{}
return &models.Account{
Uid: uid,
Name: accountModifyReq.Name,
DisplayOrder: order,
Category: accountModifyReq.Category,
Type: accountType,
Icon: accountModifyReq.Icon,
Color: accountModifyReq.Color,
Currency: *accountModifyReq.Currency,
Balance: *accountModifyReq.Balance,
Comment: accountModifyReq.Comment,
Extend: accountExtend,
}
}
func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models.AccountCreateRequest) ([]*models.Account, []int64) {
if len(accountCreateReq.SubAccounts) <= 0 {
return nil
return nil, nil
}
childrenAccounts := make([]*models.Account, len(accountCreateReq.SubAccounts))
childrenAccountBalanceTimes := make([]int64, len(accountCreateReq.SubAccounts))
for i := int32(0); i < int32(len(accountCreateReq.SubAccounts)); i++ {
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], i+1)
childrenAccounts[i] = a.createNewAccountModel(uid, accountCreateReq.SubAccounts[i], true, i+1)
childrenAccountBalanceTimes[i] = accountCreateReq.SubAccounts[i].BalanceTime
}
return childrenAccounts
return childrenAccounts, childrenAccountBalanceTimes
}
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account) *models.Account {
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
newAccountExtend := &models.AccountExtend{}
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
}
newAccount := &models.Account{
AccountId: oldAccount.AccountId,
Uid: uid,
@@ -441,6 +769,7 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
Icon: accountModifyReq.Icon,
Color: accountModifyReq.Color,
Comment: accountModifyReq.Comment,
Extend: newAccountExtend,
Hidden: accountModifyReq.Hidden,
}
@@ -453,5 +782,40 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
return newAccount
}
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
return newAccount
}
oldAccountExtend := oldAccount.Extend
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
return newAccount
}
return nil
}
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
newSubAccountIds := make(map[int64]bool, len(accountModifyReq.SubAccounts))
for i := 0; i < len(accountModifyReq.SubAccounts); i++ {
newSubAccountIds[accountModifyReq.SubAccounts[i].Id] = true
}
toDeleteAccountIds := make([]int64, 0)
for i := 0; i < len(accountAndSubAccounts); i++ {
subAccount := accountAndSubAccounts[i]
if subAccount.AccountId == mainAccount.AccountId {
continue
}
if _, exists := newSubAccountIds[subAccount.AccountId]; !exists {
toDeleteAccountIds = append(toDeleteAccountIds, subAccount.AccountId)
}
}
return toDeleteAccountIds
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const amapCustomMapStylesUrl = "https://webapi.amap.com/v4/map/styles"
const amapOverseasMapUrl = "https://fmap01.amap.com/v3/vectormap"
const amapRestApiUrl = "https://restapi.amap.com/"
// AmapApiProxy represents amap api proxy
type AmapApiProxy struct {
ApiUsingConfig
}
// Initialize a amap api proxy singleton instance
var (
AmapApis = &AmapApiProxy{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// AmapApiProxyHandler returns amap api response
func (p *AmapApiProxy) AmapApiProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
var targetUrl string
if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v4/map/styles") {
targetUrl = amapCustomMapStylesUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/v4/map/styles")
} else if strings.HasPrefix(c.Request.RequestURI, "/_AMapService/v3/vectormap") {
targetUrl = amapOverseasMapUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/v3/vectormap")
} else {
targetUrl = amapRestApiUrl + strings.TrimPrefix(c.Request.URL.Path, "/_AMapService/")
}
director := func(req *http.Request) {
targetRawUrl := fmt.Sprintf("%s?%s&jscode=%s", targetUrl, req.URL.RawQuery, p.CurrentConfig().AmapApplicationSecret)
targetUrl, _ := url.Parse(targetRawUrl)
oldCookies := req.Cookies()
req.Header.Del("Cookie")
for i := 0; i < len(oldCookies); i++ {
if strings.HasPrefix(oldCookies[i].Name, "ebk_") {
continue
}
req.AddCookie(oldCookies[i])
}
req.URL = targetUrl
req.RequestURI = req.URL.RequestURI()
req.Host = targetUrl.Host
}
return &httputil.ReverseProxy{Director: director}, nil
}
+165 -47
View File
@@ -3,15 +3,21 @@ package api
import (
"github.com/pquerna/otp/totp"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// AuthorizationsApi represents authorization api
type AuthorizationsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
ApiWithUserInfo
users *services.UserService
tokens *services.TokenService
twoFactorAuthorizations *services.TwoFactorAuthorizationService
@@ -20,6 +26,23 @@ type AuthorizationsApi struct {
// Initialize a authorization api singleton instance
var (
Authorizations = &AuthorizationsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
container: avatars.Container,
},
},
users: services.Users,
tokens: services.Tokens,
twoFactorAuthorizations: services.TwoFactorAuthorizations,
@@ -27,35 +50,72 @@ var (
)
// AuthorizeHandler verifies and authorizes current login request
func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
var credential models.UserLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrLoginNameOrPasswordInvalid
}
user, err := a.users.GetUserByUsernameOrEmailAndPassword(credential.LoginName, credential.Password)
err = a.CheckFailureCount(c, 0)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
user, uid, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
if errs.IsCustomError(err) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
}
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
return nil, errs.ErrLoginNameOrPasswordWrong
}
err = a.users.UpdateUserLastLoginTime(user.Uid)
if user.Disabled {
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user is disabled", credential.LoginName)
return nil, errs.ErrUserIsDisabled
}
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
hasValidEmailVerifyToken, err := a.tokens.ExistsValidTokenByType(c, user.Uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] failed check whether user \"uid:%d\" has valid verify email token, because %s", user.Uid, err.Error())
hasValidEmailVerifyToken = false
}
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user has not verified email", credential.LoginName)
return nil, errs.NewErrorWithContext(errs.ErrEmailIsNotVerified, map[string]any{
"email": user.Email,
"hasValidEmailVerifyToken": hasValidEmailVerifyToken,
})
}
err = a.users.UpdateUserLastLoginTime(c, user.Uid)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error())
}
twoFactorEnable := a.tokens.CurrentConfig().EnableTwoFactor
if twoFactorEnable {
twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(user.Uid)
twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, user.Uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to check two factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.AuthorizeHandler] failed to check two-factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
}
@@ -64,91 +124,128 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *err
var claims *core.UserTokenClaims
if twoFactorEnable {
token, claims, err = a.tokens.CreateRequire2FAToken(user, c)
token, claims, err = a.tokens.CreateRequire2FAToken(c, user)
} else {
token, claims, err = a.tokens.CreateToken(user, c)
token, claims, err = a.tokens.CreateToken(c, user)
}
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
if !twoFactorEnable {
c.SetTextualToken(token)
}
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
authResp := a.getAuthResponse(token, twoFactorEnable, user)
authResp := a.getAuthResponse(c, token, twoFactorEnable, user)
return authResp, nil
}
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
var credential models.TwoFactorLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrPasscodeInvalid
}
uid := c.GetCurrentUid()
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid)
err = a.CheckFailureCount(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err != nil {
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
err = a.CheckAndIncreaseFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
return nil, errs.ErrPasscodeInvalid
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
return nil, errs.ErrUserNotFound
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
if user.Disabled {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
token, claims, err := a.tokens.CreateToken(user, c)
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two-factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt)
authResp := a.getAuthResponse(token, false, user)
authResp := a.getAuthResponse(c, token, false, user)
return authResp, nil
}
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
var credential models.TwoFactorRecoveryCodeLoginRequest
err := c.ShouldBindJSON(&credential)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrTwoFactorRecoveryCodeInvalid
}
uid := c.GetCurrentUid()
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
err = a.CheckFailureCount(c, uid)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrSystemError)
}
@@ -156,46 +253,67 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
return nil, errs.ErrTwoFactorIsNotEnabled
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
return nil, errs.ErrUserNotFound
}
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(uid, credential.RecoveryCode, user.Salt)
if user.Disabled {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
if errs.IsCustomError(err) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
}
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist)
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(user, c)
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
log.Infof(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two-factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt)
authResp := a.getAuthResponse(token, false, user)
authResp := a.getAuthResponse(c, token, false, user)
return authResp, nil
}
func (a *AuthorizationsApi) getAuthResponse(token string, need2FA bool, user *models.User) *models.AuthResponse {
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User) *models.AuthResponse {
return &models.AuthResponse{
Token: token,
Need2FA: need2FA,
User: user.ToUserBasicInfo(),
Token: token,
Need2FA: need2FA,
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale()),
}
}
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"fmt"
"sort"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
// ApiUsingConfig represents an api that need to use config
type ApiUsingConfig struct {
container *settings.ConfigContainer
}
// CurrentConfig returns the current config
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
return a.container.Current
}
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
func (a *ApiUsingConfig) GetTransactionPictureInfoResponse(pictureInfo *models.TransactionPictureInfo) *models.TransactionPictureInfoBasicResponse {
originalUrl := fmt.Sprintf(internalTransactionPictureUrlFormat, a.CurrentConfig().RootUrl, pictureInfo.PictureId, pictureInfo.PictureExtension)
return pictureInfo.ToTransactionPictureInfoBasicResponse(originalUrl)
}
// GetTransactionPictureInfoResponseList returns the view-object list of transaction picture basic info according to the transaction picture model
func (a *ApiUsingConfig) GetTransactionPictureInfoResponseList(pictureInfos []*models.TransactionPictureInfo) models.TransactionPictureInfoBasicResponseSlice {
pictureInfoResps := make(models.TransactionPictureInfoBasicResponseSlice, len(pictureInfos))
for i := 0; i < len(pictureInfos); i++ {
pictureInfoResps[i] = a.GetTransactionPictureInfoResponse(pictureInfos[i])
}
sort.Sort(pictureInfoResps)
return pictureInfoResps
}
// GetAfterRegisterNotificationContent returns the notification content displayed each time users register
func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string, clientLanguage string) string {
language := userLanguage
if language == "" {
language = clientLanguage
}
if !a.container.Current.AfterRegisterNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterRegisterNotification.DefaultContent
}
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, clientLanguage string) string {
language := userLanguage
if language == "" {
language = clientLanguage
}
if !a.container.Current.AfterLoginNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterLoginNotification.DefaultContent
}
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, clientLanguage string) string {
language := userLanguage
if language == "" {
language = clientLanguage
}
if !a.container.Current.AfterOpenNotification.Enabled {
return ""
}
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent
}
return a.container.Current.AfterOpenNotification.DefaultContent
}
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
type ApiUsingDuplicateChecker struct {
ApiUsingConfig
container *duplicatechecker.DuplicateCheckerContainer
}
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) (bool, string) {
return a.container.GetSubmissionRemark(checkerType, uid, identification)
}
// SetSubmissionRemarkIfEnable saves the identification and remark by the current duplicate checker if the duplicate submission check is enabled
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string) {
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
}
}
// RemoveSubmissionRemarkIfEnable removes the identification and remark by the current duplicate checker if the duplicate submission check is enabled
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
}
}
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
func (a *ApiUsingDuplicateChecker) CheckFailureCount(c *core.WebContext, uid int64) error {
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
clientIp := c.ClientIP()
ipFailureCount := a.container.GetFailureCount(clientIp)
if ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
return errs.ErrFailureCountLimitReached
}
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
uidFailureCount := a.container.GetFailureCount(utils.Int64ToString(uid))
if uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
return errs.ErrFailureCountLimitReached
}
}
return nil
}
// CheckAndIncreaseFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
func (a *ApiUsingDuplicateChecker) CheckAndIncreaseFailureCount(c *core.WebContext, uid int64) error {
clientIp := c.ClientIP()
ipFailureCount := uint32(0)
uidFailureCount := uint32(0)
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
ipFailureCount = a.container.GetFailureCount(clientIp)
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
uidFailureCount = a.container.GetFailureCount(utils.Int64ToString(uid))
}
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount < a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", previous failure count: %d", clientIp, ipFailureCount)
a.container.IncreaseFailureCount(clientIp)
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount < a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", previous failure count: %d", uid, uidFailureCount)
a.container.IncreaseFailureCount(utils.Int64ToString(uid))
}
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
return errs.ErrFailureCountLimitReached
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
return errs.ErrFailureCountLimitReached
}
return nil
}
// ApiUsingAvatarProvider represents an api that need to use avatar provider
type ApiUsingAvatarProvider struct {
container *avatars.AvatarProviderContainer
}
// GetAvatarUrl returns the avatar url by the current user avatar provider
func (a *ApiUsingAvatarProvider) GetAvatarUrl(user *models.User) string {
return a.container.GetAvatarUrl(user)
}
// ApiWithUserInfo represents an api that can returns user info
type ApiWithUserInfo struct {
ApiUsingConfig
ApiUsingAvatarProvider
}
// GetUserBasicInfo returns the view-object of user basic info according to the user model
func (a *ApiWithUserInfo) GetUserBasicInfo(user *models.User) *models.UserBasicInfo {
return user.ToUserBasicInfo(a.CurrentConfig().AvatarProvider, a.GetAvatarUrl(user))
}
+183 -114
View File
@@ -19,162 +19,127 @@ const pageCountForDataExport = 1000
// DataManagementsApi represents data management api
type DataManagementsApi struct {
exporter *converters.EzBookKeepingCSVFileExporter
tokens *services.TokenService
users *services.UserService
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
ApiUsingConfig
tokens *services.TokenService
users *services.UserService
accounts *services.AccountService
transactions *services.TransactionService
categories *services.TransactionCategoryService
tags *services.TransactionTagService
pictures *services.TransactionPictureService
templates *services.TransactionTemplateService
userCustomExchangeRates *services.UserCustomExchangeRatesService
}
// Initialize a data management api singleton instance
var (
DataManagements = &DataManagementsApi{
exporter: &converters.EzBookKeepingCSVFileExporter{},
tokens: services.Tokens,
users: services.Users,
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
tokens: services.Tokens,
users: services.Users,
accounts: services.Accounts,
transactions: services.Transactions,
categories: services.TransactionCategories,
tags: services.TransactionTags,
pictures: services.TransactionPictures,
templates: services.TransactionTemplates,
userCustomExchangeRates: services.UserCustomExchangeRates,
}
)
// ExportDataHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataHandler(c *core.Context) ([]byte, string, *errs.Error) {
if !settings.Container.Current.EnableDataExport {
return nil, "", errs.ErrDataExportNotAllowed
}
// ExportDataToEzbookkeepingCSVHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataToEzbookkeepingCSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
return a.getExportedFileContent(c, "csv")
}
timezone := time.Local
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
} else {
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, "", errs.ErrUserNotFound
}
accounts, err := a.accounts.GetAllAccountsByUid(uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
categories, err := a.categories.GetAllCategoriesByUid(uid, 0, -1)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tags, err := a.tags.GetAllTagsByUid(uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tagIndexs, err := a.tags.GetAllTagIdsOfAllTransactions(uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
accountMap := a.accounts.GetAccountMapByList(accounts)
categoryMap := a.categories.GetCategoryMapByList(categories)
tagMap := a.tags.GetTagMapByList(tags)
allTransactions, err := a.transactions.GetAllTransactions(uid, pageCountForDataExport, true)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
result, err := a.exporter.ToExportedContent(uid, timezone, allTransactions, accountMap, categoryMap, tagMap, tagIndexs)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
fileName := a.getFileName(user, timezone)
return result, fileName, nil
// ExportDataToEzbookkeepingTSVHandler returns exported data in csv format
func (a *DataManagementsApi) ExportDataToEzbookkeepingTSVHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
return a.getExportedFileContent(c, "tsv")
}
// DataStatisticsHandler returns user data statistics
func (a *DataManagementsApi) DataStatisticsHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
totalAccountCount, err := a.accounts.GetTotalAccountCountByUid(uid)
totalAccountCount, err := a.accounts.GetTotalAccountCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total account count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total account count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionCategoryCount, err := a.categories.GetTotalCategoryCountByUid(uid)
totalTransactionCategoryCount, err := a.categories.GetTotalCategoryCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction category count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction category count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionTagCount, err := a.tags.GetTotalTagCountByUid(uid)
totalTransactionTagCount, err := a.tags.GetTotalTagCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction tag count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction tag count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionCount, err := a.transactions.GetTotalTransactionCountByUid(uid)
totalTransactionCount, err := a.transactions.GetTotalTransactionCountByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.DataStatisticsHandler] failed to get total transaction count for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionPictureCount, err := a.pictures.GetTotalTransactionPicturesCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction picture count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalTransactionTemplateCount, err := a.templates.GetTotalNormalTemplateCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total transaction template count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
totalScheduledTransactionCount, err := a.templates.GetTotalScheduledTemplateCountByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.DataStatisticsHandler] failed to get total scheduled transaction count for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
dataStatisticsResp := &models.DataStatisticsResponse{
TotalAccountCount: totalAccountCount,
TotalTransactionCategoryCount: totalTransactionCategoryCount,
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalAccountCount: totalAccountCount,
TotalTransactionCategoryCount: totalTransactionCategoryCount,
TotalTransactionTagCount: totalTransactionTagCount,
TotalTransactionCount: totalTransactionCount,
TotalTransactionPictureCount: totalTransactionPictureCount,
TotalTransactionTemplateCount: totalTransactionTemplateCount,
TotalScheduledTransactionCount: totalScheduledTransactionCount,
}
return dataStatisticsResp, nil
}
// ClearDataHandler deletes all user data
func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearDataRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.WarnfWithRequestId(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
@@ -184,36 +149,140 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.Context) (interface{}, *er
return nil, errs.ErrUserPasswordWrong
}
err = a.transactions.DeleteAllTransactions(uid)
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
err = a.templates.DeleteAllTemplates(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction templates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.categories.DeleteAllCategories(uid)
err = a.transactions.DeleteAllTransactions(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.tags.DeleteAllTags(uid)
err = a.categories.DeleteAllCategories(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
err = a.tags.DeleteAllTags(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
return true, nil
}
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location) string {
func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType string) ([]byte, string, *errs.Error) {
if !a.CurrentConfig().EnableDataExport {
return nil, "", errs.ErrDataExportNotAllowed
}
timezone := time.Local
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[data_managements.ExportDataHandler] cannot get client timezone offset, because %s", err.Error())
} else {
timezone = time.FixedZone("Client Timezone", int(utcOffset)*60)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ExportDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, "", errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION) {
return nil, "", errs.ErrNotPermittedToPerformThisAction
}
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
categories, err := a.categories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tags, err := a.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
tagIndexes, err := a.tags.GetAllTagIdsMapOfAllTransactions(c, uid)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
accountMap := a.accounts.GetAccountMapByList(accounts)
categoryMap := a.categories.GetCategoryMapByList(categories)
tagMap := a.tags.GetTagMapByList(tags)
allTransactions, err := a.transactions.GetAllTransactions(c, uid, pageCountForDataExport, true)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.ErrOperationFailed
}
dataExporter := converters.GetTransactionDataExporter(fileType)
if dataExporter == nil {
return nil, "", errs.ErrNotImplemented
}
result, err := dataExporter.ToExportedContent(c, uid, allTransactions, accountMap, categoryMap, tagMap, tagIndexes)
if err != nil {
log.Errorf(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
fileName := a.getFileName(user, timezone, fileType)
return result, fileName, nil
}
func (a *DataManagementsApi) getFileName(user *models.User, timezone *time.Location, fileExtension string) string {
currentTime := utils.FormatUnixTimeToLongDateTimeWithoutSecond(time.Now().Unix(), timezone)
currentTime = strings.Replace(currentTime, "-", "_", -1)
currentTime = strings.Replace(currentTime, " ", "_", -1)
currentTime = strings.Replace(currentTime, ":", "_", -1)
return fmt.Sprintf("%s_%s.csv", user.Username, currentTime)
return fmt.Sprintf("%s_%s.%s", user.Username, currentTime, fileExtension)
}
+2 -2
View File
@@ -14,11 +14,11 @@ var (
)
// ApiNotFound returns api not found error
func (a *DefaultApi) ApiNotFound(c *core.Context) (interface{}, *errs.Error) {
func (a *DefaultApi) ApiNotFound(c *core.WebContext) (any, *errs.Error) {
return nil, errs.ErrApiNotFound
}
// MethodNotAllowed returns method not allowed error
func (a *DefaultApi) MethodNotAllowed(c *core.Context) (interface{}, *errs.Error) {
func (a *DefaultApi) MethodNotAllowed(c *core.WebContext) (any, *errs.Error) {
return nil, errs.ErrMethodNotAllowed
}
+84 -72
View File
@@ -1,100 +1,112 @@
package api
import (
"io"
"net/http"
"sort"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// ExchangeRatesApi represents exchange rate api
type ExchangeRatesApi struct{}
type ExchangeRatesApi struct {
ApiUsingConfig
users *services.UserService
userCustomExchangeRates *services.UserCustomExchangeRatesService
}
// Initialize a exchange rate api singleton instance
var (
ExchangeRates = &ExchangeRatesApi{}
ExchangeRates = &ExchangeRatesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
users: services.Users,
userCustomExchangeRates: services.UserCustomExchangeRates,
}
)
// LatestExchangeRateHandler returns latest exchange rate data
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
dataSource := exchangerates.Container.Current
if dataSource == nil {
return nil, errs.ErrInvalidExchangeRatesDataSource
}
uid := c.GetCurrentUid()
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(c, c.GetCurrentUid(), a.container.Current)
client := &http.Client{
Timeout: time.Duration(settings.Container.Current.ExchangeRatesRequestTimeout) * time.Millisecond,
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
urls := dataSource.GetRequestUrls()
exchangeRateResps := make([]*models.LatestExchangeRateResponse, 0, len(urls))
for i := 0; i < len(urls); i++ {
resp, err := client.Get(urls[i])
if err != nil {
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
if resp.StatusCode != 200 {
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
exchangeRateResp, err := dataSource.Parse(c, body)
if err != nil {
log.ErrorfWithRequestId(c, "[exchange_rates.LatestExchangeRateHandler] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
}
exchangeRateResps = append(exchangeRateResps, exchangeRateResp)
}
lastExchangeRateResponse := exchangeRateResps[len(exchangeRateResps)-1]
allExchangeRatesMap := make(map[string]string)
for i := 0; i < len(exchangeRateResps); i++ {
exchangeRateResp := exchangeRateResps[i]
for j := 0; j < len(exchangeRateResp.ExchangeRates); j++ {
exchangeRate := exchangeRateResp.ExchangeRates[j]
allExchangeRatesMap[exchangeRate.Currency] = exchangeRate.Rate
}
}
allExchangeRatesMap[lastExchangeRateResponse.BaseCurrency] = "1"
allExchangeRates := make(models.LatestExchangeRateSlice, 0, len(allExchangeRatesMap))
for currency, rate := range allExchangeRatesMap {
allExchangeRates = append(allExchangeRates, &models.LatestExchangeRate{
Currency: currency,
Rate: rate,
})
}
sort.Sort(allExchangeRates)
finalExchangeRateResponse := &models.LatestExchangeRateResponse{
DataSource: lastExchangeRateResponse.DataSource,
ReferenceUrl: lastExchangeRateResponse.ReferenceUrl,
UpdateTime: lastExchangeRateResponse.UpdateTime,
BaseCurrency: lastExchangeRateResponse.BaseCurrency,
ExchangeRates: allExchangeRates,
}
return finalExchangeRateResponse, nil
return exchangeRateResponse, nil
}
// UserCustomExchangeRateUpdateHandler updates user custom exchange rates data by request parameters for current user
func (a *ExchangeRatesApi) UserCustomExchangeRateUpdateHandler(c *core.WebContext) (any, *errs.Error) {
var customExchangeRateUpdateReq models.UserCustomExchangeRateUpdateRequest
err := c.ShouldBindJSON(&customExchangeRateUpdateReq)
if err != nil {
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if customExchangeRateUpdateReq.Currency == user.DefaultCurrency {
return nil, errs.ErrCannotUpdateExchangeRateForDefaultCurrency
}
newCustomExchangeRate, defaultCurrencyExchangeRate, err := a.userCustomExchangeRates.UpdateCustomExchangeRate(c, uid, customExchangeRateUpdateReq.Currency, customExchangeRateUpdateReq.Rate, user.DefaultCurrency)
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] failed to update user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateUpdateReq.Currency, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[exchange_rates.UserCustomExchangeRateUpdateHandler] user \"uid:%d\" has updated user custom exchange rate \"currency:%s\" successfully", uid, customExchangeRateUpdateReq.Currency)
return newCustomExchangeRate.ToUserCustomExchangeRateUpdateResponse(defaultCurrencyExchangeRate.Rate), nil
}
// UserCustomExchangeRateDeleteHandler deletes an existed user custom exchange rates data by request parameters for current user
func (a *ExchangeRatesApi) UserCustomExchangeRateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var customExchangeRateDeleteReq models.UserCustomExchangeRateDeleteRequest
err := c.ShouldBindJSON(&customExchangeRateDeleteReq)
if err != nil {
log.Warnf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to get user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if customExchangeRateDeleteReq.Currency == user.DefaultCurrency {
return nil, errs.ErrCannotDeleteExchangeRateForDefaultCurrency
}
err = a.userCustomExchangeRates.DeleteCustomExchangeRate(c, uid, customExchangeRateDeleteReq.Currency)
if err != nil {
log.Errorf(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] failed to delete user custom exchange rate \"currency:%s\" for user \"uid:%d\", because %s", customExchangeRateDeleteReq.Currency, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[exchange_rates.UserCustomExchangeRateDeleteHandler] user \"uid:%d\" has deleted user custom exchange rate \"currency:%s\"", uid, customExchangeRateDeleteReq.Currency)
return true, nil
}
+164
View File
@@ -0,0 +1,164 @@
package api
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// ForgetPasswordsApi represents user forget password api
type ForgetPasswordsApi struct {
ApiUsingConfig
users *services.UserService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
}
// Initialize a user api singleton instance
var (
ForgetPasswords = &ForgetPasswordsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
users: services.Users,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
}
)
// UserForgetPasswordRequestHandler generates password reset link and send user an email with this link
func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext) (any, *errs.Error) {
var request models.ForgetPasswordRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrEmailIsEmptyOrInvalid
}
user, err := a.users.GetUserByEmail(c, request.Email)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreatePasswordResetToken(c, user)
if err != nil {
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
go func() {
err = a.forgetPasswords.SendPasswordResetEmail(c, user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
return true, nil
}
// UserResetPasswordHandler resets user password by request parameters
func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any, *errs.Error) {
var request models.PasswordResetRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
if a.CurrentConfig().ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
if user.Email != request.Email {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
return nil, errs.ErrEmptyIsInvalid
}
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
if err != nil {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke password reset token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
return nil, errs.ErrNewPasswordEqualsOldInvalid
}
userNew := &models.User{
Uid: user.Uid,
Salt: user.Salt,
Password: request.Password,
}
_, _, err = a.users.UpdateUser(c, userNew, false)
if err != nil {
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.Infof(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
return true, nil
}
+1 -1
View File
@@ -15,7 +15,7 @@ var (
)
// HealthStatusHandler returns the health status of current service
func (a *HealthsApi) HealthStatusHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
result := make(map[string]string)
result["version"] = settings.Version
+103 -11
View File
@@ -1,34 +1,123 @@
package api
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const openStreetMapTileImageUrlFormat = "https://tile.openstreetmap.org/%s/%s/%s" // https://tile.openstreetmap.org/{z}/{x}/{y}.png
const openStreetMapTileImageUrlFormat = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" // https://tile.openstreetmap.org/{z}/{x}/{y}.png
const openStreetMapHumanitarianStyleTileImageUrlFormat = "https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png" // https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png
const openTopoMapTileImageUrlFormat = "https://tile.opentopomap.org/{z}/{x}/{y}.png" // https://tile.opentopomap.org/{z}/{x}/{y}.png
const opnvKarteMapTileImageUrlFormat = "https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png" // https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png
const cyclOSMMapTileImageUrlFormat = "https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png" // https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png
const cartoDBMapTileImageUrlFormat = "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{scale}.png" // https://{s}.basemaps.cartocdn.com/{style}/{z}/{x}/{y}{scale}.png
const tomtomMapTileImageUrlFormat = "https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png" // https://api.tomtom.com/map/{versionNumber}/tile/{layer}/{style}/{z}/{x}/{y}.png?key={key}&language={language}
const tianDiTuMapTileImageUrlFormat = "https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}" // https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}
const tianDiTuMapAnnotationUrlFormat = "https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}" // https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}
// MapImageProxy represents map image proxy
type MapImageProxy struct {
ApiUsingConfig
}
// Initialize a map image proxy singleton instance
var (
MapImages = &MapImageProxy{}
MapImages = &MapImageProxy{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// OpenStreetMapTileImageProxyHandler returns open street map tile image
func (p *MapImageProxy) OpenStreetMapTileImageProxyHandler(c *core.Context) (*httputil.ReverseProxy, *errs.Error) {
director := func(req *http.Request) {
zoomLevel := c.Param("zoomLevel")
coordinateX := c.Param("coordinateX")
fileName := c.Param("fileName")
// MapTileImageProxyHandler returns map tile image
func (p *MapImageProxy) MapTileImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
if mapProvider == settings.OpenStreetMapProvider {
return openStreetMapTileImageUrlFormat, nil
} else if mapProvider == settings.OpenStreetMapHumanitarianStyleProvider {
return openStreetMapHumanitarianStyleTileImageUrlFormat, nil
} else if mapProvider == settings.OpenTopoMapProvider {
return openTopoMapTileImageUrlFormat, nil
} else if mapProvider == settings.OPNVKarteMapProvider {
return opnvKarteMapTileImageUrlFormat, nil
} else if mapProvider == settings.CyclOSMMapProvider {
return cyclOSMMapTileImageUrlFormat, nil
} else if mapProvider == settings.CartoDBMapProvider {
return cartoDBMapTileImageUrlFormat, nil
} else if mapProvider == settings.TomTomMapProvider {
targetUrl := tomtomMapTileImageUrlFormat + "?key=" + p.CurrentConfig().TomTomMapAPIKey
language := c.Query("language")
imageRawUrl := fmt.Sprintf(openStreetMapTileImageUrlFormat, zoomLevel, coordinateX, fileName)
if language != "" {
targetUrl = targetUrl + "&language=" + language
}
return targetUrl, nil
} else if mapProvider == settings.TianDiTuProvider {
return tianDiTuMapTileImageUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
} else if mapProvider == settings.CustomProvider {
return p.CurrentConfig().CustomMapTileServerTileLayerUrl, nil
}
return "", errs.ErrParameterInvalid
})
}
// MapAnnotationImageProxyHandler returns map annotation image
func (p *MapImageProxy) MapAnnotationImageProxyHandler(c *core.WebContext) (*httputil.ReverseProxy, *errs.Error) {
return p.mapImageProxyHandler(c, func(c *core.WebContext, mapProvider string) (string, *errs.Error) {
if mapProvider == settings.TianDiTuProvider {
return tianDiTuMapAnnotationUrlFormat + "&tk=" + p.CurrentConfig().TianDiTuAPIKey, nil
} else if mapProvider == settings.CustomProvider {
return p.CurrentConfig().CustomMapTileServerAnnotationLayerUrl, nil
}
return "", errs.ErrParameterInvalid
})
}
func (p *MapImageProxy) mapImageProxyHandler(c *core.WebContext, fn func(c *core.WebContext, mapProvider string) (string, *errs.Error)) (*httputil.ReverseProxy, *errs.Error) {
mapProvider := strings.Replace(c.Query("provider"), "-", "_", -1)
targetUrl := ""
if mapProvider != p.CurrentConfig().MapProvider {
return nil, errs.ErrMapProviderNotCurrent
}
zoomLevel := c.Param("zoomLevel")
coordinateX := c.Param("coordinateX")
fileName := c.Param("fileName")
fileNameParts := strings.Split(fileName, ".")
coordinateY := fileNameParts[0]
scale := c.Query("scale")
if len(fileNameParts) != 2 || fileNameParts[len(fileNameParts)-1] != "png" {
return nil, errs.ErrImageExtensionNotSupported
}
var err *errs.Error
targetUrl, err = fn(c, mapProvider)
if err != nil {
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, p.CurrentConfig().MapProxy)
director := func(req *http.Request) {
imageRawUrl := targetUrl
imageRawUrl = strings.Replace(imageRawUrl, "{z}", zoomLevel, -1)
imageRawUrl = strings.Replace(imageRawUrl, "{x}", coordinateX, -1)
imageRawUrl = strings.Replace(imageRawUrl, "{y}", coordinateY, -1)
imageRawUrl = strings.Replace(imageRawUrl, "{scale}", scale, -1)
imageUrl, _ := url.Parse(imageRawUrl)
req.URL = imageUrl
@@ -36,5 +125,8 @@ func (p *MapImageProxy) OpenStreetMapTileImageProxyHandler(c *core.Context) (*ht
req.Host = imageUrl.Host
}
return &httputil.ReverseProxy{Director: director}, nil
return &httputil.ReverseProxy{
Transport: transport,
Director: director,
}, nil
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"bytes"
"image/png"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const (
qrCodeDefaultWidth int = 320
qrCodeDefaultHeight int = 320
)
// QrCodesApi represents qrcode generator api
type QrCodesApi struct {
ApiUsingConfig
}
// Initialize a qrcode generator api singleton instance
var (
QrCodes = &QrCodesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// MobileUrlQrCodeHandler returns a mobile url qr code image
func (a *QrCodesApi) MobileUrlQrCodeHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
fullUrl := a.CurrentConfig().RootUrl + "mobile"
data, err := a.generateUrlQrCode(c, fullUrl)
if err != nil {
return nil, "", errs.ErrOperationFailed
}
return data, "image/png", nil
}
func (a *QrCodesApi) generateUrlQrCode(c *core.WebContext, url string) ([]byte, *errs.Error) {
qrCodeImg, _ := qr.Encode(url, qr.M, qr.Auto)
qrCodeImg, _ = barcode.Scale(qrCodeImg, qrCodeDefaultWidth, qrCodeDefaultHeight)
imgData := &bytes.Buffer{}
if err := png.Encode(imgData, qrCodeImg); err != nil {
return nil, errs.ErrOperationFailed
}
return imgData.Bytes(), nil
}
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"fmt"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const ezbookkeepingServerSettingsGlobalVariableName = "EZBOOKKEEPING_SERVER_SETTINGS"
const ezbookkeepingServerSettingsGlobalVariableFullName = "window." + ezbookkeepingServerSettingsGlobalVariableName
const ezbookkeepingServerSettingsJavascriptFileHeader = ezbookkeepingServerSettingsGlobalVariableFullName +
"=" + ezbookkeepingServerSettingsGlobalVariableFullName + "||{};\n"
// ServerSettingsApi represents server settings api
type ServerSettingsApi struct {
ApiUsingConfig
}
// Initialize a server settings api singleton instance
var (
ServerSettings = &ServerSettingsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
}
)
// ServerSettingsJavascriptHandler returns the javascript contains server settings
func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
config := a.CurrentConfig()
builder := &strings.Builder{}
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
if config.LoginPageTips.Enabled {
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
}
a.appendStringSetting(builder, "m", config.MapProvider)
if config.EnableMapDataFetchProxy &&
(config.MapProvider == settings.OpenStreetMapProvider ||
config.MapProvider == settings.OpenStreetMapHumanitarianStyleProvider ||
config.MapProvider == settings.OpenTopoMapProvider ||
config.MapProvider == settings.OPNVKarteMapProvider ||
config.MapProvider == settings.CyclOSMMapProvider ||
config.MapProvider == settings.CartoDBMapProvider ||
config.MapProvider == settings.TomTomMapProvider ||
config.MapProvider == settings.TianDiTuProvider ||
config.MapProvider == settings.CustomProvider) {
a.appendBooleanSetting(builder, "mp", config.EnableMapDataFetchProxy)
}
if config.MapProvider == settings.CustomProvider {
a.appendStringSetting(builder, "cmzl", fmt.Sprintf("%d-%d-%d", config.CustomMapTileServerMinZoomLevel, config.CustomMapTileServerMaxZoomLevel, config.CustomMapTileServerDefaultZoomLevel))
if !config.EnableMapDataFetchProxy {
a.appendStringSetting(builder, "cmsu", config.CustomMapTileServerTileLayerUrl)
if config.CustomMapTileServerAnnotationLayerUrl != "" {
a.appendStringSetting(builder, "cmau", config.CustomMapTileServerAnnotationLayerUrl)
}
} else {
if config.CustomMapTileServerAnnotationLayerUrl != "" {
a.appendBooleanSetting(builder, "cmap", config.EnableMapDataFetchProxy)
}
}
}
if config.MapProvider == settings.TomTomMapProvider && config.TomTomMapAPIKey != "" && !config.EnableMapDataFetchProxy {
a.appendStringSetting(builder, "tmak", config.TomTomMapAPIKey)
}
if config.MapProvider == settings.TianDiTuProvider && config.TianDiTuAPIKey != "" && !config.EnableMapDataFetchProxy {
a.appendStringSetting(builder, "tdak", config.TianDiTuAPIKey)
}
if config.MapProvider == settings.GoogleMapProvider && config.GoogleMapAPIKey != "" {
a.appendStringSetting(builder, "gmak", config.GoogleMapAPIKey)
}
if config.MapProvider == settings.BaiduMapProvider && config.BaiduMapAK != "" {
a.appendStringSetting(builder, "bmak", config.BaiduMapAK)
}
if config.MapProvider == settings.AmapProvider && config.AmapApplicationKey != "" {
a.appendStringSetting(builder, "amak", config.AmapApplicationKey)
}
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod != "" {
a.appendStringSetting(builder, "amsv", config.AmapSecurityVerificationMethod)
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationExternalProxyMethod {
a.appendStringSetting(builder, "amep", config.AmapApiExternalProxyUrl)
}
if config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationPlainTextMethod {
a.appendStringSetting(builder, "amas", config.AmapApplicationSecret)
}
}
if config.ExchangeRatesRequestTimeoutExceedDefaultValue {
a.appendIntegerSetting(builder, "errt", int(config.ExchangeRatesRequestTimeout))
}
return []byte(builder.String()), "", nil
}
func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key string, value string) {
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
builder.WriteString("[")
a.appendEncodedString(builder, key)
builder.WriteString("]=")
a.appendEncodedString(builder, value)
builder.WriteString(";\n")
}
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) {
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
builder.WriteString("[")
a.appendEncodedString(builder, key)
builder.WriteString("]={\n")
builder.WriteString("'default'")
builder.WriteRune(':')
a.appendEncodedString(builder, value.DefaultContent)
for languageTag, content := range value.MultiLanguageContent {
builder.WriteString(",\n")
a.appendEncodedString(builder, languageTag)
builder.WriteRune(':')
a.appendEncodedString(builder, content)
}
builder.WriteString("\n};\n")
}
func (a *ServerSettingsApi) appendBooleanSetting(builder *strings.Builder, key string, value bool) {
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
builder.WriteString("[")
a.appendEncodedString(builder, key)
builder.WriteString("]=")
if value {
builder.WriteRune('1')
} else {
builder.WriteRune('0')
}
builder.WriteString(";\n")
}
func (a *ServerSettingsApi) appendIntegerSetting(builder *strings.Builder, key string, value int) {
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
builder.WriteString("[")
a.appendEncodedString(builder, key)
builder.WriteString("]=")
builder.WriteString(utils.IntToString(value))
builder.WriteString(";\n")
}
func (a *ServerSettingsApi) appendEncodedString(builder *strings.Builder, content string) {
builder.WriteRune('\'')
runes := []rune(content)
for i := 0; i < len(runes); i++ {
switch runes[i] {
case '\\':
builder.WriteRune('\\')
builder.WriteRune('\\')
case '\'':
builder.WriteRune('\\')
builder.WriteRune('\'')
case '\n':
builder.WriteRune('\\')
builder.WriteRune('n')
case '\r':
builder.WriteRune('\\')
builder.WriteRune('r')
case '\t':
builder.WriteRune('\\')
builder.WriteRune('t')
case '\f':
builder.WriteRune('\\')
builder.WriteRune('f')
case '\b':
builder.WriteRune('\\')
builder.WriteRune('b')
default:
builder.WriteRune(runes[i])
}
}
builder.WriteRune('\'')
}
+117 -33
View File
@@ -2,17 +2,22 @@ package api
import (
"sort"
"time"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// TokensApi represents token api
type TokensApi struct {
ApiUsingConfig
ApiWithUserInfo
tokens *services.TokenService
users *services.UserService
}
@@ -20,18 +25,29 @@ type TokensApi struct {
// Initialize a token api singleton instance
var (
Tokens = &TokensApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
container: avatars.Container,
},
},
tokens: services.Tokens,
users: services.Users,
}
)
// TokenListHandler returns available token list of current user
func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(uid)
tokens, err := a.tokens.GetAllUnexpiredNormalTokensByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -44,8 +60,7 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error)
TokenId: a.tokens.GenerateTokenId(token),
TokenType: token.TokenType,
UserAgent: token.UserAgent,
CreatedAt: token.CreatedUnixTime,
ExpiredAt: token.ExpiredUnixTime,
LastSeen: token.LastSeenUnixTime,
}
if token.Uid == claims.Uid && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt {
@@ -61,7 +76,7 @@ func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error)
}
// TokenRevokeCurrentHandler revokes current token of current user
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
_, claims, err := a.tokens.ParseTokenByHeader(c)
if err != nil {
@@ -71,7 +86,7 @@ func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (interface{}, *er
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
if err != nil {
log.WarnfWithRequestId(c, "[tokens.TokenRevokeCurrentHandler] parse user token id failed, because %s", err.Error())
log.Warnf(c, "[tokens.TokenRevokeCurrentHandler] parse user token id failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -82,24 +97,24 @@ func (a *TokensApi) TokenRevokeCurrentHandler(c *core.Context) (interface{}, *er
}
tokenId := a.tokens.GenerateTokenId(tokenRecord)
err = a.tokens.DeleteToken(tokenRecord)
err = a.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
log.Errorf(c, "[token.TokenRevokeCurrentHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
log.Infof(c, "[token.TokenRevokeCurrentHandler] user \"uid:%d\" has revoked token \"id:%s\"", claims.Uid, tokenId)
return true, nil
}
// TokenRevokeHandler revokes specific token of current user
func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRevokeHandler(c *core.WebContext) (any, *errs.Error) {
var tokenRevokeReq models.TokenRevokeRequest
err := c.ShouldBindJSON(&tokenRevokeReq)
if err != nil {
log.WarnfWithRequestId(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -107,7 +122,7 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Erro
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
log.Errorf(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error())
}
return nil, errs.Or(err, errs.ErrInvalidTokenId)
@@ -116,28 +131,44 @@ func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Erro
uid := c.GetCurrentUid()
if tokenRecord.Uid != uid {
log.WarnfWithRequestId(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
log.Warnf(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid)
return nil, errs.ErrInvalidTokenId
}
err = a.tokens.DeleteToken(tokenRecord)
if utils.Int64ToString(tokenRecord.UserTokenId) != c.GetTokenClaims().UserTokenId || tokenRecord.CreatedUnixTime != c.GetTokenClaims().IssuedAt {
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[token.TokenRevokeHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
}
err = a.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
log.Errorf(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
log.Infof(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId)
return true, nil
}
// TokenRevokeAllHandler revokes all tokens of current user except current token
func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRevokeAllHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tokens, err := a.tokens.GetAllTokensByUid(uid)
tokens, err := a.tokens.GetAllTokensByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[tokens.TokenRevokeAllHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[tokens.TokenRevokeAllHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -155,35 +186,86 @@ func (a *TokensApi) TokenRevokeAllHandler(c *core.Context) (interface{}, *errs.E
tokens = append(tokens[:currentTokenIndex], tokens[currentTokenIndex+1:]...)
err = a.tokens.DeleteTokens(uid, tokens)
if len(tokens) < 1 {
return nil, errs.ErrTokenRecordNotFound
}
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
if !errs.IsCustomError(err) {
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
err = a.tokens.DeleteTokens(c, uid, tokens)
if err != nil {
log.Errorf(c, "[token.TokenRevokeAllHandler] failed to revoke all tokens for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
log.Infof(c, "[token.TokenRevokeAllHandler] user \"uid:%d\" has revoked all tokens", uid)
return true, nil
}
// TokenRefreshHandler refresh current token of current user
func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
log.Warnf(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
return nil, errs.ErrUserNotFound
}
token, claims, err := a.tokens.CreateToken(user, c)
now := time.Now().Unix()
oldTokenClaims := c.GetTokenClaims()
if now-oldTokenClaims.IssuedAt < int64(a.CurrentConfig().TokenMinRefreshInterval) {
log.Infof(c, "[token.TokenRefreshHandler] token of user \"uid:%d\" does not need to be refreshed", uid)
userTokenId, err := utils.StringToInt64(oldTokenClaims.UserTokenId)
if err != nil {
log.Warnf(c, "[tokens.TokenRefreshHandler] parse user token id failed, because %s", err.Error())
} else {
tokenRecord := &models.TokenRecord{
Uid: oldTokenClaims.Uid,
UserTokenId: userTokenId,
CreatedUnixTime: oldTokenClaims.IssuedAt,
}
tokenId := a.tokens.GenerateTokenId(tokenRecord)
err = a.tokens.UpdateTokenLastSeen(c, tokenRecord)
if err != nil {
log.Warnf(c, "[token.TokenRefreshHandler] failed to update last seen of token \"id:%s\" for user \"uid:%d\", because %s", tokenId, uid, err.Error())
}
}
refreshResp := &models.TokenRefreshResponse{
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
}
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrTokenGenerating)
}
oldTokenClaims := c.GetTokenClaims()
oldUserTokenId, _ := utils.StringToInt64(oldTokenClaims.UserTokenId)
oldTokenRecord := &models.TokenRecord{
Uid: uid,
@@ -191,14 +273,16 @@ func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Err
CreatedUnixTime: oldTokenClaims.IssuedAt,
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
refreshResp := &models.TokenRefreshResponse{
NewToken: token,
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
User: user.ToUserBasicInfo(),
NewToken: token,
OldTokenId: a.tokens.GenerateTokenId(oldTokenRecord),
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterOpenNotificationContent(user.Language, c.GetClientLocale()),
}
return refreshResp, nil
+247 -164
View File
@@ -3,40 +3,56 @@ package api
import (
"sort"
"github.com/gin-gonic/gin/binding"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// TransactionCategoriesApi represents transaction category api
type TransactionCategoriesApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
categories *services.TransactionCategoryService
}
// Initialize a transaction category api singleton instance
var (
TransactionCategories = &TransactionCategoriesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
categories: services.TransactionCategories,
}
)
// CategoryListHandler returns transaction category list of current user
func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryListHandler(c *core.WebContext) (any, *errs.Error) {
var categoryListReq models.TransactionCategoryListRequest
err := c.ShouldBindQuery(&categoryListReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryListHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryListHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories, err := a.categories.GetAllCategoriesByUid(uid, categoryListReq.Type, categoryListReq.ParentId)
categories, err := a.categories.GetAllCategoriesByUid(c, uid, categoryListReq.Type, categoryListReq.ParentId)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryListHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryListHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -44,20 +60,20 @@ func (a *TransactionCategoriesApi) CategoryListHandler(c *core.Context) (interfa
}
// CategoryGetHandler returns one specific transaction category of current user
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.WebContext) (any, *errs.Error) {
var categoryGetReq models.TransactionCategoryGetRequest
err := c.ShouldBindQuery(&categoryGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
category, err := a.categories.GetCategoryByCategoryId(uid, categoryGetReq.Id)
category, err := a.categories.GetCategoryByCategoryId(c, uid, categoryGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryGetHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryGetReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryGetHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -67,37 +83,37 @@ func (a *TransactionCategoriesApi) CategoryGetHandler(c *core.Context) (interfac
}
// CategoryCreateHandler saves a new transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.WebContext) (any, *errs.Error) {
var categoryCreateReq models.TransactionCategoryCreateRequest
err := c.ShouldBindJSON(&categoryCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if categoryCreateReq.Type < models.CATEGORY_TYPE_INCOME || categoryCreateReq.Type > models.CATEGORY_TYPE_TRANSFER {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] category type invalid, type is %d", categoryCreateReq.Type)
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] category type invalid, type is %d", categoryCreateReq.Type)
return nil, errs.ErrTransactionCategoryTypeInvalid
}
uid := c.GetCurrentUid()
if categoryCreateReq.ParentId > 0 {
parentCategory, err := a.categories.GetCategoryByCategoryId(uid, categoryCreateReq.ParentId)
parentCategory, err := a.categories.GetCategoryByCategoryId(c, uid, categoryCreateReq.ParentId)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get parent category \"id:%d\" for user \"uid:%d\", because %s", categoryCreateReq.ParentId, uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get parent category \"id:%d\" for user \"uid:%d\", because %s", categoryCreateReq.ParentId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if parentCategory == nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" does not exist for user \"uid:%d\"", categoryCreateReq.ParentId, uid)
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" does not exist for user \"uid:%d\"", categoryCreateReq.ParentId, uid)
return nil, errs.ErrParentTransactionCategoryNotFound
}
if parentCategory.ParentCategoryId > 0 {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" has another parent category \"id:%d\" for user \"uid:%d\"", parentCategory.CategoryId, parentCategory.ParentCategoryId, uid)
log.Warnf(c, "[transaction_categories.CategoryCreateHandler] parent category \"id:%d\" has another parent category \"id:%d\" for user \"uid:%d\"", parentCategory.CategoryId, parentCategory.ParentCategoryId, uid)
return nil, errs.ErrCannotAddToSecondaryTransactionCategory
}
}
@@ -105,44 +121,243 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (inter
var maxOrderId int32
if categoryCreateReq.ParentId <= 0 {
maxOrderId, err = a.categories.GetMaxDisplayOrder(uid, categoryCreateReq.Type)
maxOrderId, err = a.categories.GetMaxDisplayOrder(c, uid, categoryCreateReq.Type)
} else {
maxOrderId, err = a.categories.GetMaxSubCategoryDisplayOrder(uid, categoryCreateReq.Type, categoryCreateReq.ParentId)
maxOrderId, err = a.categories.GetMaxSubCategoryDisplayOrder(c, uid, categoryCreateReq.Type, categoryCreateReq.ParentId)
}
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
category := a.createNewCategoryModel(uid, &categoryCreateReq, maxOrderId+1)
err = a.categories.CreateCategory(category)
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId)
if found {
log.Infof(c, "[transaction_categories.CategoryCreateHandler] another category \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
categoryId, err := utils.StringToInt64(remark)
if err == nil {
category, err = a.categories.GetCategoryByCategoryId(c, uid, categoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to get existed category \"id:%d\" for user \"uid:%d\", because %s", categoryId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
categoryResp := category.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
}
}
err = a.categories.CreateCategory(c, category)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to create category \"id:%d\" for user \"uid:%d\", because %s", category.CategoryId, uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateHandler] failed to create category \"id:%d\" for user \"uid:%d\", because %s", category.CategoryId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
log.Infof(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId)
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId))
categoryResp := category.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
// CategoryCreateBatchHandler saves some new transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
var categoryCreateBatchReq models.TransactionCategoryCreateBatchRequest
err := c.ShouldBindJSON(&categoryCreateBatchReq)
err := c.ShouldBindBodyWith(&categoryCreateBatchReq, binding.JSON)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_categories.CategoryCreateBatchHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories, err := a.createBatchCategories(c, uid, &categoryCreateBatchReq)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return a.getTransactionCategoryListByTypeResponse(categories, 0)
}
// CategoryModifyHandler saves an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.WebContext) (any, *errs.Error) {
var categoryModifyReq models.TransactionCategoryModifyRequest
err := c.ShouldBindJSON(&categoryModifyReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
category, err := a.categories.GetCategoryByCategoryId(c, uid, categoryModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newCategory := &models.TransactionCategory{
CategoryId: category.CategoryId,
Uid: uid,
ParentCategoryId: categoryModifyReq.ParentId,
Name: categoryModifyReq.Name,
Icon: categoryModifyReq.Icon,
Color: categoryModifyReq.Color,
Comment: categoryModifyReq.Comment,
Hidden: categoryModifyReq.Hidden,
}
if newCategory.ParentCategoryId == category.ParentCategoryId &&
newCategory.Name == category.Name &&
newCategory.Icon == category.Icon &&
newCategory.Color == category.Color &&
newCategory.Comment == category.Comment &&
newCategory.Hidden == category.Hidden {
return nil, errs.ErrNothingWillBeUpdated
}
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionCategoryToSecondary)
}
if category.ParentCategoryId != models.LevelOneTransactionCategoryParentId && newCategory.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowChangeSecondaryTransactionCategoryToPrimary)
}
if newCategory.ParentCategoryId != category.ParentCategoryId {
fromPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, category.ParentCategoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get old primary category \"id:%d\" of category \"id:%d\" for user \"uid:%d\", because %s", category.ParentCategoryId, categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
toPrimaryCategory, err := a.categories.GetCategoryByCategoryId(c, uid, newCategory.ParentCategoryId)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to get new primary category \"id:%d\" of category \"id:%d\" for user \"uid:%d\", because %s", newCategory.ParentCategoryId, categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if fromPrimaryCategory.Type != toPrimaryCategory.Type {
return nil, errs.Or(err, errs.ErrNotAllowChangePrimaryTransactionType)
}
if toPrimaryCategory.ParentCategoryId != models.LevelOneTransactionCategoryParentId {
return nil, errs.Or(err, errs.ErrNotAllowUseSecondaryTransactionAsPrimaryCategory)
}
}
err = a.categories.ModifyCategory(c, newCategory)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryModifyHandler] failed to update category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
newCategory.Type = category.Type
newCategory.DisplayOrder = category.DisplayOrder
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
// CategoryHideHandler hides an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.WebContext) (any, *errs.Error) {
var categoryHideReq models.TransactionCategoryHideRequest
err := c.ShouldBindJSON(&categoryHideReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.HideCategory(c, uid, []int64{categoryHideReq.Id}, categoryHideReq.Hidden)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryHideHandler] failed to hide category \"id:%d\" for user \"uid:%d\", because %s", categoryHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
return true, nil
}
// CategoryMoveHandler moves display order of existed transaction categories by request parameters for current user
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.WebContext) (any, *errs.Error) {
var categoryMoveReq models.TransactionCategoryMoveRequest
err := c.ShouldBindJSON(&categoryMoveReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories := make([]*models.TransactionCategory, len(categoryMoveReq.NewDisplayOrders))
for i := 0; i < len(categoryMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := categoryMoveReq.NewDisplayOrders[i]
category := &models.TransactionCategory{
Uid: uid,
CategoryId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
categories[i] = category
}
err = a.categories.ModifyCategoryDisplayOrders(c, uid, categories)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
return true, nil
}
// CategoryDeleteHandler deletes an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var categoryDeleteReq models.TransactionCategoryDeleteRequest
err := c.ShouldBindJSON(&categoryDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.DeleteCategory(c, uid, categoryDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_categories.CategoryDeleteHandler] failed to delete category \"id:%d\" for user \"uid:%d\", because %s", categoryDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
return true, nil
}
func (a *TransactionCategoriesApi) createBatchCategories(c *core.WebContext, uid int64, categoryCreateBatchReq *models.TransactionCategoryCreateBatchRequest) ([]*models.TransactionCategory, error) {
var err error
categoryTypeMaxOrderMap := make(map[models.TransactionCategoryType]int32)
categoriesMap := make(map[*models.TransactionCategory][]*models.TransactionCategory)
categoriesMap[nil] = make([]*models.TransactionCategory, len(categoryCreateBatchReq.Categories))
@@ -153,10 +368,10 @@ func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (
var maxOrderId, exists = categoryTypeMaxOrderMap[categoryCreateReq.Type]
if !exists {
maxOrderId, err = a.categories.GetMaxDisplayOrder(uid, categoryCreateReq.Type)
maxOrderId, err = a.categories.GetMaxDisplayOrder(c, uid, categoryCreateReq.Type)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.CategoryCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
}
@@ -181,148 +396,16 @@ func (a *TransactionCategoriesApi) CategoryCreateBatchHandler(c *core.Context) (
totalCount++
}
categories, err := a.categories.CreateCategories(uid, categoriesMap)
categories, err := a.categories.CreateCategories(c, uid, categoriesMap)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] failed to create categories for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_categories.createBatchCategories] failed to create categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateBatchHandler] user \"uid:%d\" has created categoroies successfully", uid)
log.Infof(c, "[transaction_categories.createBatchCategories] user \"uid:%d\" has created categories successfully", uid)
return a.getTransactionCategoryListByTypeResponse(categories, 0)
}
// CategoryModifyHandler saves an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryModifyHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryModifyReq models.TransactionCategoryModifyRequest
err := c.ShouldBindJSON(&categoryModifyReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
category, err := a.categories.GetCategoryByCategoryId(uid, categoryModifyReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] failed to get category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
newCategory := &models.TransactionCategory{
CategoryId: category.CategoryId,
Uid: uid,
Name: categoryModifyReq.Name,
Icon: categoryModifyReq.Icon,
Color: categoryModifyReq.Color,
Comment: categoryModifyReq.Comment,
Hidden: categoryModifyReq.Hidden,
}
if newCategory.Name == category.Name &&
newCategory.Icon == category.Icon &&
newCategory.Color == category.Color &&
newCategory.Comment == category.Comment &&
newCategory.Hidden == category.Hidden {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.categories.ModifyCategory(newCategory)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryModifyHandler] failed to update category \"id:%d\" for user \"uid:%d\", because %s", categoryModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryModifyHandler] user \"uid:%d\" has updated category \"id:%d\" successfully", uid, categoryModifyReq.Id)
newCategory.Type = category.Type
newCategory.ParentCategoryId = category.ParentCategoryId
newCategory.DisplayOrder = category.DisplayOrder
categoryResp := newCategory.ToTransactionCategoryInfoResponse()
return categoryResp, nil
}
// CategoryHideHandler hides an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryHideHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryHideReq models.TransactionCategoryHideRequest
err := c.ShouldBindJSON(&categoryHideReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.HideCategory(uid, []int64{categoryHideReq.Id}, categoryHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryHideHandler] failed to hide category \"id:%d\" for user \"uid:%d\", because %s", categoryHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, categoryHideReq.Id)
return true, nil
}
// CategoryMoveHandler moves display order of existed transaction categories by request parameters for current user
func (a *TransactionCategoriesApi) CategoryMoveHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryMoveReq models.TransactionCategoryMoveRequest
err := c.ShouldBindJSON(&categoryMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
categories := make([]*models.TransactionCategory, len(categoryMoveReq.NewDisplayOrders))
for i := 0; i < len(categoryMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := categoryMoveReq.NewDisplayOrders[i]
category := &models.TransactionCategory{
Uid: uid,
CategoryId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
categories[i] = category
}
err = a.categories.ModifyCategoryDisplayOrders(uid, categories)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryMoveHandler] failed to move categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
return true, nil
}
// CategoryDeleteHandler deletes an existed transaction category by request parameters for current user
func (a *TransactionCategoriesApi) CategoryDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
var categoryDeleteReq models.TransactionCategoryDeleteRequest
err := c.ShouldBindJSON(&categoryDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.categories.DeleteCategory(uid, categoryDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] failed to delete category \"id:%d\" for user \"uid:%d\", because %s", categoryDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_categories.CategoryDeleteHandler] user \"uid:%d\" has deleted category \"id:%d\"", uid, categoryDeleteReq.Id)
return true, nil
return categories, nil
}
func (a *TransactionCategoriesApi) createNewCategoryModel(uid int64, categoryCreateReq *models.TransactionCategoryCreateRequest, order int32) *models.TransactionCategory {
@@ -350,7 +433,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
for i := 0; i < len(categoryResps); i++ {
categoryResp := categoryResps[i]
if categoryResp.ParentId <= models.LevelOneTransactionParentId {
if categoryResp.ParentId <= models.LevelOneTransactionCategoryParentId {
continue
}
@@ -366,7 +449,7 @@ func (a *TransactionCategoriesApi) getTransactionCategoryListByTypeResponse(cate
finalCategoryResps := make(models.TransactionCategoryInfoResponseSlice, 0)
for i := 0; i < len(categoryResps); i++ {
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionParentId {
if parentId <= 0 && categoryResps[i].ParentId == models.LevelOneTransactionCategoryParentId {
sort.Sort(categoryResps[i].SubCategories)
finalCategoryResps = append(finalCategoryResps, categoryResps[i])
} else if parentId > 0 && categoryResps[i].ParentId == parentId {
+183
View File
@@ -0,0 +1,183 @@
package api
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// TransactionPicturesApi represents transaction pictures api
type TransactionPicturesApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
users *services.UserService
pictures *services.TransactionPictureService
}
// Initialize a transaction api singleton instance
var (
TransactionPictures = &TransactionPicturesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
users: services.Users,
pictures: services.TransactionPictures,
}
)
// TransactionPictureUploadHandler saves transaction picture by request parameters for current user
func (a *TransactionPicturesApi) TransactionPictureUploadHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
pictureFiles := form.File["picture"]
if len(pictureFiles) < 1 {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] there is no transaction picture in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoTransactionPicture
}
if pictureFiles[0].Size < 1 {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the size of transaction picture in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrTransactionPictureIsEmpty
}
if pictureFiles[0].Size > int64(a.CurrentConfig().MaxTransactionPictureFileSize) {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of transaction picture for user \"uid:%d\"", pictureFiles[0].Size, a.CurrentConfig().MaxTransactionPictureFileSize, uid)
return nil, errs.ErrExceedMaxTransactionPictureFileSize
}
fileExtension := utils.GetFileNameExtension(pictureFiles[0].Filename)
if utils.GetImageContentType(fileExtension) == "" {
log.Warnf(c, "[transaction_pictures.TransactionPictureUploadHandler] the file extension \"%s\" of transaction picture in request is not supported for user \"uid:%d\"", fileExtension, uid)
return nil, errs.ErrImageTypeNotSupported
}
pictureFile, err := pictureFiles[0].Open()
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get transaction picture file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
pictureInfo := a.createNewPictureInfoModel(uid, fileExtension, c.ClientIP())
clientSessionIds := form.Value["clientSessionId"]
clientSessionId := ""
if len(clientSessionIds) > 0 {
clientSessionId = clientSessionIds[0]
}
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && clientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId)
if found {
log.Infof(c, "[transaction_pictures.TransactionPictureUploadHandler] another transaction picture \"id:%s\" has been uploaded for user \"uid:%d\"", remark, uid)
pictureId, err := utils.StringToInt64(remark)
if err == nil {
pictureInfo, err = a.pictures.GetPictureInfoByPictureId(c, uid, pictureId)
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get existed transaction picture \"id:%d\" for user \"uid:%d\", because %s", pictureId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
return pictureInfoResp, nil
}
}
}
err = a.pictures.UploadPicture(c, pictureInfo, pictureFile)
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to update transaction picture for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_PICTURE, uid, clientSessionId, utils.Int64ToString(pictureInfo.PictureId))
pictureInfoResp := a.GetTransactionPictureInfoResponse(pictureInfo)
return pictureInfoResp, nil
}
// TransactionPictureGetHandler returns transaction picture data for current user
func (a *TransactionPicturesApi) TransactionPictureGetHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
fileName := c.Param("fileName")
fileExtension := utils.GetFileNameExtension(fileName)
contentType := utils.GetImageContentType(fileExtension)
if contentType == "" {
return nil, "", errs.ErrImageTypeNotSupported
}
fileBaseName := utils.GetFileNameWithoutExtension(fileName)
pictureId, err := utils.StringToInt64(fileBaseName)
if err != nil {
return nil, "", errs.ErrTransactionPictureIdInvalid
}
uid := c.GetCurrentUid()
pictureData, err := a.pictures.GetPictureByPictureId(c, uid, pictureId, fileExtension)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transaction_pictures.TransactionPictureUploadHandler] failed to get transaction picture, because %s", err.Error())
}
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
return pictureData, contentType, nil
}
// TransactionPictureRemoveUnusedHandler removes unused transaction picture by request parameters for current user
func (a *TransactionPicturesApi) TransactionPictureRemoveUnusedHandler(c *core.WebContext) (any, *errs.Error) {
var pictureDeleteReq models.TransactionPictureUnusedDeleteRequest
err := c.ShouldBindJSON(&pictureDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_pictures.TransactionPictureRemoveUnusedHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.pictures.RemoveUnusedTransactionPicture(c, uid, pictureDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_pictures.TransactionPictureRemoveUnusedHandler] failed to remove unused transaction picture for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
func (a *TransactionPicturesApi) createNewPictureInfoModel(uid int64, fileExtension string, clientIp string) *models.TransactionPictureInfo {
return &models.TransactionPictureInfo{
Uid: uid,
TransactionId: models.TransactionPictureNewPictureTransactionId,
PictureExtension: fileExtension,
CreatedIp: clientIp,
}
}
+93 -40
View File
@@ -23,12 +23,12 @@ var (
)
// TagListHandler returns transaction tag list of current user
func (a *TransactionTagsApi) TagListHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagListHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
tags, err := a.tags.GetAllTagsByUid(uid)
tags, err := a.tags.GetAllTagsByUid(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagListHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_tags.TagListHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -44,20 +44,20 @@ func (a *TransactionTagsApi) TagListHandler(c *core.Context) (interface{}, *errs
}
// TagGetHandler returns one specific transaction tag of current user
func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagGetHandler(c *core.WebContext) (any, *errs.Error) {
var tagGetReq models.TransactionTagGetRequest
err := c.ShouldBindQuery(&tagGetReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagGetHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tag, err := a.tags.GetTagByTagId(uid, tagGetReq.Id)
tag, err := a.tags.GetTagByTagId(c, uid, tagGetReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagGetHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagGetReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagGetHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -67,55 +67,96 @@ func (a *TransactionTagsApi) TagGetHandler(c *core.Context) (interface{}, *errs.
}
// TagCreateHandler saves a new transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagCreateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Error) {
var tagCreateReq models.TransactionTagCreateRequest
err := c.ShouldBindJSON(&tagCreateReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagCreateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
maxOrderId, err := a.tags.GetMaxDisplayOrder(uid)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tag := a.createNewTagModel(uid, &tagCreateReq, maxOrderId+1)
err = a.tags.CreateTag(tag)
err = a.tags.CreateTag(c, tag)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagCreateHandler] failed to create tag \"id:%d\" for user \"uid:%d\", because %s", tag.TagId, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to create tag \"id:%d\" for user \"uid:%d\", because %s", tag.TagId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.TagCreateHandler] user \"uid:%d\" has created a new tag \"id:%d\" successfully", uid, tag.TagId)
log.Infof(c, "[transaction_tags.TagCreateHandler] user \"uid:%d\" has created a new tag \"id:%d\" successfully", uid, tag.TagId)
tagResp := tag.ToTransactionTagInfoResponse()
return tagResp, nil
}
// TagModifyHandler saves an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *errs.Error) {
var tagModifyReq models.TransactionTagModifyRequest
err := c.ShouldBindJSON(&tagModifyReq)
// TagCreateBatchHandler saves some new transaction tags by request parameters for current user
func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *errs.Error) {
var tagCreateBatchReq models.TransactionTagCreateBatchRequest
err := c.ShouldBindJSON(&tagCreateBatchReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagModifyHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tag, err := a.tags.GetTagByTagId(uid, tagModifyReq.Id)
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagModifyHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tags := a.createNewTagModels(uid, &tagCreateBatchReq, maxOrderId+1)
err = a.tags.CreateTags(c, uid, tags, tagCreateBatchReq.SkipExists)
if err != nil {
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to create tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_tags.TagCreateBatchHandler] user \"uid:%d\" has created tags successfully", uid)
tagResps := make(models.TransactionTagInfoResponseSlice, len(tags))
for i := 0; i < len(tags); i++ {
tagResps[i] = tags[i].ToTransactionTagInfoResponse()
}
sort.Sort(tagResps)
return tagResps, nil
}
// TagModifyHandler saves an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Error) {
var tagModifyReq models.TransactionTagModifyRequest
err := c.ShouldBindJSON(&tagModifyReq)
if err != nil {
log.Warnf(c, "[transaction_tags.TagModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
tag, err := a.tags.GetTagByTagId(c, uid, tagModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -129,14 +170,14 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *er
return nil, errs.ErrNothingWillBeUpdated
}
err = a.tags.ModifyTag(newTag)
err = a.tags.ModifyTag(c, newTag)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to update tag \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
log.Infof(c, "[transaction_tags.TagModifyHandler] user \"uid:%d\" has updated tag \"id:%d\" successfully", uid, tagModifyReq.Id)
tag.Name = newTag.Name
tagResp := tag.ToTransactionTagInfoResponse()
@@ -144,35 +185,35 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.Context) (interface{}, *er
return tagResp, nil
}
// TagHideHandler hides an transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagHideHandler(c *core.Context) (interface{}, *errs.Error) {
// TagHideHandler hides a transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagHideHandler(c *core.WebContext) (any, *errs.Error) {
var tagHideReq models.TransactionTagHideRequest
err := c.ShouldBindJSON(&tagHideReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.CategoryHideHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.tags.HideTag(uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
err = a.tags.HideTag(c, uid, []int64{tagHideReq.Id}, tagHideReq.Hidden)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.CategoryHideHandler] failed to hide tag \"id:%d\" for user \"uid:%d\", because %s", tagHideReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagHideHandler] failed to hide tag \"id:%d\" for user \"uid:%d\", because %s", tagHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.CategoryHideHandler] user \"uid:%d\" has hidden category \"id:%d\"", uid, tagHideReq.Id)
log.Infof(c, "[transaction_tags.TagHideHandler] user \"uid:%d\" has hidden tag \"id:%d\"", uid, tagHideReq.Id)
return true, nil
}
// TagMoveHandler moves display order of existed transaction tags by request parameters for current user
func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagMoveHandler(c *core.WebContext) (any, *errs.Error) {
var tagMoveReq models.TransactionTagMoveRequest
err := c.ShouldBindJSON(&tagMoveReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.CategoryMoveHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
@@ -190,36 +231,36 @@ func (a *TransactionTagsApi) TagMoveHandler(c *core.Context) (interface{}, *errs
tags[i] = tag
}
err = a.tags.ModifyTagDisplayOrders(uid, tags)
err = a.tags.ModifyTagDisplayOrders(c, uid, tags)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.CategoryMoveHandler] failed to move tags for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[transaction_tags.TagMoveHandler] failed to move tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.CategoryMoveHandler] user \"uid:%d\" has moved categories", uid)
log.Infof(c, "[transaction_tags.TagMoveHandler] user \"uid:%d\" has moved tags", uid)
return true, nil
}
// TagDeleteHandler deletes an existed transaction tag by request parameters for current user
func (a *TransactionTagsApi) TagDeleteHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TransactionTagsApi) TagDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var tagDeleteReq models.TransactionTagDeleteRequest
err := c.ShouldBindJSON(&tagDeleteReq)
if err != nil {
log.WarnfWithRequestId(c, "[transaction_tags.TagDeleteHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[transaction_tags.TagDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
err = a.tags.DeleteTag(uid, tagDeleteReq.Id)
err = a.tags.DeleteTag(c, uid, tagDeleteReq.Id)
if err != nil {
log.ErrorfWithRequestId(c, "[transaction_tags.TagDeleteHandler] failed to delete tag \"id:%d\" for user \"uid:%d\", because %s", tagDeleteReq.Id, uid, err.Error())
log.Errorf(c, "[transaction_tags.TagDeleteHandler] failed to delete tag \"id:%d\" for user \"uid:%d\", because %s", tagDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[transaction_tags.TagDeleteHandler] user \"uid:%d\" has deleted tag \"id:%d\"", uid, tagDeleteReq.Id)
log.Infof(c, "[transaction_tags.TagDeleteHandler] user \"uid:%d\" has deleted tag \"id:%d\"", uid, tagDeleteReq.Id)
return true, nil
}
@@ -230,3 +271,15 @@ func (a *TransactionTagsApi) createNewTagModel(uid int64, tagCreateReq *models.T
DisplayOrder: order,
}
}
func (a *TransactionTagsApi) createNewTagModels(uid int64, tagCreateBatchReq *models.TransactionTagCreateBatchRequest, order int32) []*models.TransactionTag {
tags := make([]*models.TransactionTag, len(tagCreateBatchReq.Tags))
for i := 0; i < len(tagCreateBatchReq.Tags); i++ {
tagCreateReq := tagCreateBatchReq.Tags[i]
tag := a.createNewTagModel(uid, tagCreateReq, order+int32(i))
tags[i] = tag
}
return tags
}
+558
View File
@@ -0,0 +1,558 @@
package api
import (
"sort"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const maximumTagsCountOfTemplate = 10
// TransactionTemplatesApi represents transaction template api
type TransactionTemplatesApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
templates *services.TransactionTemplateService
}
// Initialize a transaction template api singleton instance
var (
TransactionTemplates = &TransactionTemplatesApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
templates: services.TransactionTemplates,
}
)
// TemplateListHandler returns transaction template list of current user
func (a *TransactionTemplatesApi) TemplateListHandler(c *core.WebContext) (any, *errs.Error) {
var templateListReq models.TransactionTemplateListRequest
err := c.ShouldBindQuery(&templateListReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateListHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if templateListReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateListReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
log.Warnf(c, "[transaction_templates.TemplateListHandler] template type invalid, type is %d", templateListReq.TemplateType)
return nil, errs.ErrTransactionTemplateTypeInvalid
}
if templateListReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
uid := c.GetCurrentUid()
templates, err := a.templates.GetAllTemplatesByUid(c, uid, templateListReq.TemplateType)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateListHandler] failed to get templates for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
templateResps := make(models.TransactionTemplateInfoResponseSlice, len(templates))
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
for i := 0; i < len(templates); i++ {
templateResps[i] = templates[i].ToTransactionTemplateInfoResponse(serverUtcOffset)
}
sort.Sort(templateResps)
return templateResps, nil
}
// TemplateGetHandler returns one specific transaction template of current user
func (a *TransactionTemplatesApi) TemplateGetHandler(c *core.WebContext) (any, *errs.Error) {
var templateGetReq models.TransactionTemplateGetRequest
err := c.ShouldBindQuery(&templateGetReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateGetHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateGetReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateGetHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateGetReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
// TemplateCreateHandler saves a new transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any, *errs.Error) {
var templateCreateReq models.TransactionTemplateCreateRequest
err := c.ShouldBindJSON(&templateCreateReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if templateCreateReq.TemplateType < models.TRANSACTION_TEMPLATE_TYPE_NORMAL || templateCreateReq.TemplateType > models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] template type invalid, type is %d", templateCreateReq.TemplateType)
return nil, errs.ErrTransactionTemplateTypeInvalid
}
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
if templateCreateReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateCreateReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transaction_templates.TemplateCreateHandler] transaction type invalid, type is %d", templateCreateReq.Type)
return nil, errs.ErrTransactionTypeInvalid
}
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if templateCreateReq.ScheduledFrequencyType == nil ||
templateCreateReq.ScheduledFrequency == nil ||
templateCreateReq.ScheduledTimezoneUtcOffset == nil {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
if *templateCreateReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency != "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
} else if *templateCreateReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateCreateReq.ScheduledFrequency == "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
}
if len(templateCreateReq.TagIds) > maximumTagsCountOfTemplate {
return nil, errs.ErrTransactionTemplateHasTooManyTags
}
uid := c.GetCurrentUid()
maxOrderId, err := a.templates.GetMaxDisplayOrder(c, uid, templateCreateReq.TemplateType)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to get max display order for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
if found {
log.Infof(c, "[transaction_templates.TemplateCreateHandler] another template \"id:%s\" has been created for user \"uid:%d\"", remark, uid)
templateId, err := utils.StringToInt64(remark)
if err == nil {
template, err = a.templates.GetTemplateByTemplateId(c, uid, templateId)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to get existed template \"id:%d\" for user \"uid:%d\", because %s", templateId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
}
}
err = a.templates.CreateTemplate(c, template)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create template \"id:%d\" for user \"uid:%d\", because %s", template.TemplateId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateCreateHandler] user \"uid:%d\" has created a new template \"id:%d\" successfully", uid, template.TemplateId)
a.SetSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId, utils.Int64ToString(template.TemplateId))
templateResp := template.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
// TemplateModifyHandler saves an existed transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any, *errs.Error) {
var templateModifyReq models.TransactionTemplateModifyRequest
err := c.ShouldBindJSON(&templateModifyReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if templateModifyReq.Type <= models.TRANSACTION_TYPE_MODIFY_BALANCE || templateModifyReq.Type > models.TRANSACTION_TYPE_TRANSFER {
log.Warnf(c, "[transaction_templates.TemplateModifyHandler] transaction type invalid, type is %d", templateModifyReq.Type)
return nil, errs.ErrTransactionTypeInvalid
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateModifyReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if templateModifyReq.ScheduledFrequencyType == nil ||
templateModifyReq.ScheduledFrequency == nil ||
templateModifyReq.ScheduledTimezoneUtcOffset == nil {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
if *templateModifyReq.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency != "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
} else if *templateModifyReq.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED && *templateModifyReq.ScheduledFrequency == "" {
return nil, errs.ErrScheduledTransactionFrequencyInvalid
}
}
if len(templateModifyReq.TagIds) > maximumTagsCountOfTemplate {
return nil, errs.ErrTransactionTemplateHasTooManyTags
}
newTemplate := &models.TransactionTemplate{
TemplateId: template.TemplateId,
Uid: uid,
Name: templateModifyReq.Name,
Type: templateModifyReq.Type,
CategoryId: templateModifyReq.CategoryId,
AccountId: templateModifyReq.SourceAccountId,
TagIds: strings.Join(templateModifyReq.TagIds, ","),
Amount: templateModifyReq.SourceAmount,
RelatedAccountId: templateModifyReq.DestinationAccountId,
RelatedAccountAmount: templateModifyReq.DestinationAmount,
HideAmount: templateModifyReq.HideAmount,
Comment: templateModifyReq.Comment,
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
newTemplate.ScheduledFrequencyType = *templateModifyReq.ScheduledFrequencyType
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
if templateModifyReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
startUnixTime := startTime.Unix()
newTemplate.ScheduledStartTime = &startUnixTime
}
if templateModifyReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
endUnixTime := endTime.Unix()
newTemplate.ScheduledEndTime = &endUnixTime
}
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
if newTemplate.Name == template.Name &&
newTemplate.Type == template.Type &&
newTemplate.CategoryId == template.CategoryId &&
newTemplate.AccountId == template.AccountId &&
newTemplate.TagIds == template.TagIds &&
newTemplate.Amount == template.Amount &&
newTemplate.RelatedAccountId == template.RelatedAccountId &&
newTemplate.RelatedAccountAmount == template.RelatedAccountAmount &&
newTemplate.HideAmount == template.HideAmount &&
newTemplate.Comment == template.Comment {
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_NORMAL {
return nil, errs.ErrNothingWillBeUpdated
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
newTemplate.ScheduledAt == template.ScheduledAt &&
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
return nil, errs.ErrNothingWillBeUpdated
}
}
}
err = a.templates.ModifyTemplate(c, newTemplate)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to update template \"id:%d\" for user \"uid:%d\", because %s", templateModifyReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateModifyHandler] user \"uid:%d\" has updated template \"id:%d\" successfully", uid, templateModifyReq.Id)
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
newTemplate.TemplateType = template.TemplateType
newTemplate.DisplayOrder = template.DisplayOrder
newTemplate.Hidden = template.Hidden
templateResp := newTemplate.ToTransactionTemplateInfoResponse(serverUtcOffset)
return templateResp, nil
}
// TemplateHideHandler hides a transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateHideHandler(c *core.WebContext) (any, *errs.Error) {
var templateHideReq models.TransactionTemplateHideRequest
err := c.ShouldBindJSON(&templateHideReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateHideHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateHideReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
err = a.templates.HideTemplate(c, uid, []int64{templateHideReq.Id}, templateHideReq.Hidden)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateHideHandler] failed to hide template \"id:%d\" for user \"uid:%d\", because %s", templateHideReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateHideHandler] user \"uid:%d\" has hidden template \"id:%d\"", uid, templateHideReq.Id)
return true, nil
}
// TemplateMoveHandler moves display order of existed transaction templates by request parameters for current user
func (a *TransactionTemplatesApi) TemplateMoveHandler(c *core.WebContext) (any, *errs.Error) {
var templateMoveReq models.TransactionTemplateMoveRequest
err := c.ShouldBindJSON(&templateMoveReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateMoveHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
if len(templateMoveReq.NewDisplayOrders) > 0 {
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateMoveReq.NewDisplayOrders[0].Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateMoveReq.NewDisplayOrders[0].Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
}
templates := make([]*models.TransactionTemplate, len(templateMoveReq.NewDisplayOrders))
for i := 0; i < len(templateMoveReq.NewDisplayOrders); i++ {
newDisplayOrder := templateMoveReq.NewDisplayOrders[i]
template := &models.TransactionTemplate{
Uid: uid,
TemplateId: newDisplayOrder.Id,
DisplayOrder: newDisplayOrder.DisplayOrder,
}
templates[i] = template
}
err = a.templates.ModifyTemplateDisplayOrders(c, uid, templates)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateMoveHandler] failed to move templates for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateMoveHandler] user \"uid:%d\" has moved templates", uid)
return true, nil
}
// TemplateDeleteHandler deletes an existed transaction template by request parameters for current user
func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any, *errs.Error) {
var templateDeleteReq models.TransactionTemplateDeleteRequest
err := c.ShouldBindJSON(&templateDeleteReq)
if err != nil {
log.Warnf(c, "[transaction_templates.TemplateDeleteHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
template, err := a.templates.GetTemplateByTemplateId(c, uid, templateDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to get template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE && !a.CurrentConfig().EnableScheduledTransaction {
return nil, errs.ErrScheduledTransactionNotEnabled
}
err = a.templates.DeleteTemplate(c, uid, templateDeleteReq.Id)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateDeleteHandler] failed to delete template \"id:%d\" for user \"uid:%d\", because %s", templateDeleteReq.Id, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[transaction_templates.TemplateDeleteHandler] user \"uid:%d\" has deleted template \"id:%d\"", uid, templateDeleteReq.Id)
return true, nil
}
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
Name: templateCreateReq.Name,
Type: templateCreateReq.Type,
CategoryId: templateCreateReq.CategoryId,
AccountId: templateCreateReq.SourceAccountId,
TagIds: strings.Join(templateCreateReq.TagIds, ","),
Amount: templateCreateReq.SourceAmount,
RelatedAccountId: templateCreateReq.DestinationAccountId,
RelatedAccountAmount: templateCreateReq.DestinationAmount,
HideAmount: templateCreateReq.HideAmount,
Comment: templateCreateReq.Comment,
DisplayOrder: order,
}
if templateCreateReq.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
template.ScheduledFrequencyType = *templateCreateReq.ScheduledFrequencyType
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
if templateCreateReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
startUnixTime := startTime.Unix()
template.ScheduledStartTime = &startUnixTime
}
if templateCreateReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
endUnixTime := endTime.Unix()
template.ScheduledEndTime = &endUnixTime
}
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
return template, nil
}
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
templateTimeZone := time.FixedZone("Template Timezone", int(scheduledTimezoneUtcOffset)*60)
transactionTime := time.Date(2020, 1, 1, 0, 0, 0, 0, templateTimeZone)
transactionTimeInUTC := transactionTime.In(time.UTC)
minutesElapsedOfDayInUtc := transactionTimeInUTC.Hour()*60 + transactionTimeInUTC.Minute()
return int16(minutesElapsedOfDayInUtc)
}
func (a *TransactionTemplatesApi) getOrderedFrequencyValues(frequencyValue string) string {
if frequencyValue == "" {
return ""
}
items := strings.Split(frequencyValue, ",")
values := make([]int, 0, len(items))
valueExistMap := make(map[int]bool)
for i := 0; i < len(items); i++ {
value, err := utils.StringToInt(items[i])
if err != nil {
continue
}
if _, exists := valueExistMap[value]; !exists {
values = append(values, value)
valueExistMap[value] = true
}
}
sort.Ints(values)
var sortedFrequencyValueBuilder strings.Builder
for i := 0; i < len(values); i++ {
if sortedFrequencyValueBuilder.Len() > 0 {
sortedFrequencyValueBuilder.WriteRune(',')
}
sortedFrequencyValueBuilder.WriteString(utils.IntToString(values[i]))
}
return sortedFrequencyValueBuilder.String()
}
+1009 -262
View File
File diff suppressed because it is too large Load Diff
+64 -51
View File
@@ -32,9 +32,9 @@ var (
)
// TwoFactorStatusHandler returns 2fa status of current user
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid)
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err == errs.ErrTwoFactorIsNotEnabled {
statusResp := &models.TwoFactorStatusResponse{
@@ -45,7 +45,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (in
}
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -58,12 +58,12 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (in
}
// TwoFactorEnableRequestHandler returns a new 2fa secret and qr code for current user to set 2fa and verify passcode next
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -71,27 +71,31 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
return nil, errs.ErrTwoFactorAlreadyEnabled
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(user)
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor secret, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor secret, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
img, err := key.Image(240, 240)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor qrcode, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two-factor qrcode, because %s", err.Error())
return nil, errs.ErrOperationFailed
}
@@ -110,20 +114,20 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Conte
}
// TwoFactorEnableConfirmHandler enables 2fa for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebContext) (any, *errs.Error) {
var confirmReq models.TwoFactorEnableConfirmRequest
err := c.ShouldBindJSON(&confirmReq)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -131,62 +135,66 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
return nil, errs.ErrTwoFactorAlreadyEnabled
}
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
twoFactorSetting := &models.TwoFactor{
Uid: uid,
Secret: confirmReq.Secret,
}
if !totp.Validate(confirmReq.Passcode, confirmReq.Secret) {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid")
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid")
return nil, errs.ErrPasscodeInvalid
}
recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes()
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt)
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(c, uid, recoveryCodes, user.Salt)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.CreateTwoFactorSetting(twoFactorSetting)
err = a.twoFactorAuthorizations.CreateTwoFactorSetting(c, twoFactorSetting)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor setting for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two-factor setting for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two factor authorization", uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two-factor authorization", uid)
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(uid, now)
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(user, c)
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
confirmResp := &models.TwoFactorEnableConfirmResponse{
RecoveryCodes: recoveryCodes,
@@ -195,9 +203,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
return confirmResp, nil
}
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
confirmResp := &models.TwoFactorEnableConfirmResponse{
Token: token,
@@ -208,34 +217,38 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Conte
}
// TwoFactorDisableHandler disables 2fa for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.WebContext) (any, *errs.Error) {
var disableReq models.TwoFactorDisableRequest
err := c.ShouldBindJSON(&disableReq)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorDisableHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
if !a.users.IsPasswordEqualsUserPassword(disableReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -243,41 +256,41 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (i
return nil, errs.ErrTwoFactorIsNotEnabled
}
err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(uid)
err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor recovery codes for user \"uid:%d\"", uid)
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two-factor recovery codes for user \"uid:%d\"", uid)
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(uid)
err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor setting for user \"uid:%d\"", uid)
log.Errorf(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two-factor setting for user \"uid:%d\"", uid)
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two factor authorization", uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two-factor authorization", uid)
return true, nil
}
// TwoFactorRecoveryCodeRegenerateHandler returns new 2fa recovery codes and revokes old recovery codes for current user
func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.WebContext) (any, *errs.Error) {
var regenerateReq models.TwoFactorRegenerateRecoveryCodeRequest
err := c.ShouldBindJSON(&regenerateReq)
if err != nil {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
log.Warnf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
@@ -287,10 +300,10 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
return nil, errs.ErrUserPasswordWrong
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid)
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two factor setting, because %s", err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two-factor setting, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -301,14 +314,14 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes()
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt)
err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(c, uid, recoveryCodes, user.Salt)
if err != nil {
log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
log.Errorf(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two-factor recovery codes for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
@@ -316,7 +329,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *c
RecoveryCodes: recoveryCodes,
}
log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two factor recovery codes", uid)
log.Infof(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two-factor recovery codes", uid)
return recoveryCodesResp, nil
}
+544 -40
View File
@@ -4,17 +4,24 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin/binding"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
// UsersApi represents user api
type UsersApi struct {
ApiUsingConfig
ApiWithUserInfo
users *services.UserService
tokens *services.TokenService
accounts *services.AccountService
@@ -23,6 +30,17 @@ type UsersApi struct {
// Initialize a user api singleton instance
var (
Users = &UsersApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingAvatarProvider: ApiUsingAvatarProvider{
container: avatars.Container,
},
},
users: services.Users,
tokens: services.Tokens,
accounts: services.Accounts,
@@ -30,21 +48,21 @@ var (
)
// UserRegisterHandler saves a new user by request parameters
func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Error) {
if !settings.Container.Current.EnableUserRegister {
func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserRegister {
return nil, errs.ErrUserRegistrationNotAllowed
}
var userRegisterReq models.UserRegisterRequest
err := c.ShouldBindJSON(&userRegisterReq)
err := c.ShouldBindBodyWith(&userRegisterReq, binding.JSON)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
if userRegisterReq.DefaultCurrency == validators.ParentAccountCurrencyPlaceholder {
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] user default currency is invalid")
log.Warnf(c, "[users.UserRegisterHandler] user default currency is invalid")
return nil, errs.ErrUserDefaultCurrencyIsInvalid
}
@@ -61,70 +79,171 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro
DefaultCurrency: userRegisterReq.DefaultCurrency,
FirstDayOfWeek: userRegisterReq.FirstDayOfWeek,
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
}
err = a.users.CreateUser(user)
err = a.users.CreateUser(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
log.Infof(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
authResp := &models.AuthResponse{
Need2FA: false,
User: user.ToUserBasicInfo(),
presetCategoriesSaved := false
if len(userRegisterReq.Categories) > 0 {
_, err = TransactionCategories.createBatchCategories(c, user.Uid, &userRegisterReq.TransactionCategoryCreateBatchRequest)
if err == nil {
presetCategoriesSaved = true
}
}
token, claims, err := a.tokens.CreateToken(user, c)
authResp := &models.RegisterResponse{
AuthResponse: models.AuthResponse{
Need2FA: false,
User: a.GetUserBasicInfo(user),
NotificationContent: a.GetAfterRegisterNotificationContent(user.Language, c.GetClientLocale()),
},
NeedVerifyEmail: a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableUserForceVerifyEmail,
PresetCategoriesSaved: presetCategoriesSaved,
}
if a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserRegisterHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
} else {
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserRegisterHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
}
}()
}
}
if a.CurrentConfig().EnableUserForceVerifyEmail {
return authResp, nil
}
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return authResp, nil
}
authResp.Token = token
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
return authResp, nil
}
// UserProfileHandler returns user profile of current user
func (a *UsersApi) UserProfileHandler(c *core.Context) (interface{}, *errs.Error) {
// UserEmailVerifyHandler sets user email address verified
func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error) {
var userVerifyEmailReq models.UserVerifyEmailRequest
err := c.ShouldBindJSON(&userVerifyEmailReq)
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserEmailVerifyHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
userResp := user.ToUserProfileResponse()
if user.Disabled {
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.Warnf(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
err = a.users.SetUserEmailVerified(c, user.Username)
if err != nil {
log.Errorf(c, "[users.UserEmailVerifyHandler] failed to update user \"uid:%d\" email address verified, because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err == nil {
log.Infof(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens for user \"uid:%d\"", user.Uid)
} else {
log.Warnf(c, "[users.UserEmailVerifyHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
resp := &models.UserVerifyEmailResponse{}
if userVerifyEmailReq.RequestNewToken {
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.Warnf(c, "[users.UserEmailVerifyHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return resp, nil
}
resp.NewToken = token
resp.User = a.GetUserBasicInfo(user)
resp.NotificationContent = a.GetAfterLoginNotificationContent(user.Language, c.GetClientLocale())
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.Infof(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
}
return resp, nil
}
// UserProfileHandler returns user profile of current user
func (a *UsersApi) UserProfileHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserUpdateProfileHandler saves user profile by request parameters for current user
func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs.Error) {
func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Error) {
var userUpdateReq models.UserProfileUpdateRequest
err := c.ShouldBindJSON(&userUpdateReq)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error())
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
@@ -133,6 +252,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
userUpdateReq.Email = strings.TrimSpace(userUpdateReq.Email)
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
modifyProfileBasicInfo := false
anythingUpdate := false
userNew := &models.User{
Uid: user.Uid,
@@ -140,12 +260,20 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
}
if userUpdateReq.Email != "" && userUpdateReq.Email != user.Email {
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
user.Email = userUpdateReq.Email
userNew.Email = userUpdateReq.Email
anythingUpdate = true
}
if userUpdateReq.Password != "" {
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
return nil, errs.ErrUserPasswordWrong
}
@@ -159,24 +287,37 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
if userUpdateReq.Nickname != "" && userUpdateReq.Nickname != user.Nickname {
user.Nickname = userUpdateReq.Nickname
userNew.Nickname = userUpdateReq.Nickname
modifyProfileBasicInfo = true
anythingUpdate = true
}
if userUpdateReq.DefaultAccountId > 0 && userUpdateReq.DefaultAccountId != user.DefaultAccountId {
accounts, err := a.accounts.GetAccountsByAccountIds(uid, []int64{userUpdateReq.DefaultAccountId})
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{userUpdateReq.DefaultAccountId})
if err != nil || len(accounts) < 1 {
if err != nil || len(accountMap) < 1 {
return nil, errs.Or(err, errs.ErrUserDefaultAccountIsInvalid)
}
if _, exists := accountMap[userUpdateReq.DefaultAccountId]; !exists {
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" does not exist for user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
return nil, errs.ErrUserDefaultAccountIsInvalid
}
if accountMap[userUpdateReq.DefaultAccountId].Hidden {
log.Warnf(c, "[users.UserUpdateProfileHandler] account \"id:%d\" is hidden of user \"uid:%d\"", userUpdateReq.DefaultAccountId, uid)
return nil, errs.ErrUserDefaultAccountIsHidden
}
user.DefaultAccountId = userUpdateReq.DefaultAccountId
userNew.DefaultAccountId = userUpdateReq.DefaultAccountId
modifyProfileBasicInfo = true
anythingUpdate = true
}
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.TransactionEditScope = models.TRANSACTION_EDIT_SCOPE_INVALID
@@ -188,96 +329,459 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
user.Language = userUpdateReq.Language
userNew.Language = userUpdateReq.Language
modifyUserLanguage = true
modifyProfileBasicInfo = true
anythingUpdate = true
}
if userUpdateReq.DefaultCurrency != "" && userUpdateReq.DefaultCurrency != user.DefaultCurrency {
user.DefaultCurrency = userUpdateReq.DefaultCurrency
userNew.DefaultCurrency = userUpdateReq.DefaultCurrency
modifyProfileBasicInfo = true
anythingUpdate = true
}
if userUpdateReq.FirstDayOfWeek != nil && *userUpdateReq.FirstDayOfWeek != user.FirstDayOfWeek {
user.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
userNew.FirstDayOfWeek = *userUpdateReq.FirstDayOfWeek
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.FirstDayOfWeek = models.WEEKDAY_INVALID
userNew.FirstDayOfWeek = core.WEEKDAY_INVALID
}
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
user.LongDateFormat = *userUpdateReq.LongDateFormat
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.LongDateFormat = models.LONG_DATE_FORMAT_INVALID
userNew.LongDateFormat = core.LONG_DATE_FORMAT_INVALID
}
if userUpdateReq.ShortDateFormat != nil && *userUpdateReq.ShortDateFormat != user.ShortDateFormat {
user.ShortDateFormat = *userUpdateReq.ShortDateFormat
userNew.ShortDateFormat = *userUpdateReq.ShortDateFormat
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.ShortDateFormat = models.SHORT_DATE_FORMAT_INVALID
userNew.ShortDateFormat = core.SHORT_DATE_FORMAT_INVALID
}
if userUpdateReq.LongTimeFormat != nil && *userUpdateReq.LongTimeFormat != user.LongTimeFormat {
user.LongTimeFormat = *userUpdateReq.LongTimeFormat
userNew.LongTimeFormat = *userUpdateReq.LongTimeFormat
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.LongTimeFormat = models.LONG_TIME_FORMAT_INVALID
userNew.LongTimeFormat = core.LONG_TIME_FORMAT_INVALID
}
if userUpdateReq.ShortTimeFormat != nil && *userUpdateReq.ShortTimeFormat != user.ShortTimeFormat {
user.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
userNew.ShortTimeFormat = *userUpdateReq.ShortTimeFormat
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.ShortTimeFormat = models.SHORT_TIME_FORMAT_INVALID
userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID
}
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.DecimalSeparator = core.DECIMAL_SEPARATOR_INVALID
}
if userUpdateReq.DigitGroupingSymbol != nil && *userUpdateReq.DigitGroupingSymbol != user.DigitGroupingSymbol {
user.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
userNew.DigitGroupingSymbol = *userUpdateReq.DigitGroupingSymbol
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.DigitGroupingSymbol = core.DIGIT_GROUPING_SYMBOL_INVALID
}
if userUpdateReq.DigitGrouping != nil && *userUpdateReq.DigitGrouping != user.DigitGrouping {
user.DigitGrouping = *userUpdateReq.DigitGrouping
userNew.DigitGrouping = *userUpdateReq.DigitGrouping
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
}
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
}
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CoordinateDisplayType = core.COORDINATE_DISPLAY_TYPE_INVALID
}
if userUpdateReq.ExpenseAmountColor != nil && *userUpdateReq.ExpenseAmountColor != user.ExpenseAmountColor {
user.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
userNew.ExpenseAmountColor = *userUpdateReq.ExpenseAmountColor
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.ExpenseAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
}
if userUpdateReq.IncomeAmountColor != nil && *userUpdateReq.IncomeAmountColor != user.IncomeAmountColor {
user.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
userNew.IncomeAmountColor = *userUpdateReq.IncomeAmountColor
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.IncomeAmountColor = models.AMOUNT_COLOR_TYPE_INVALID
}
if modifyProfileBasicInfo && user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
if modifyUserLanguage || userNew.DecimalSeparator != core.DECIMAL_SEPARATOR_INVALID || userNew.DigitGroupingSymbol != core.DIGIT_GROUPING_SYMBOL_INVALID {
decimalSeparator := userNew.DecimalSeparator
digitGroupingSymbol := userNew.DigitGroupingSymbol
if userNew.DecimalSeparator == core.DECIMAL_SEPARATOR_INVALID {
decimalSeparator = user.DecimalSeparator
}
if userNew.DigitGroupingSymbol == core.DIGIT_GROUPING_SYMBOL_INVALID {
digitGroupingSymbol = user.DigitGroupingSymbol
}
locale := user.Language
if modifyUserLanguage {
locale = userNew.Language
}
if locale == "" {
locale = c.GetClientLocale()
}
if locales.IsDecimalSeparatorEqualsDigitGroupingSymbol(decimalSeparator, digitGroupingSymbol, locale) {
return nil, errs.ErrDecimalSeparatorAndDigitGroupingSymbolCannotBeEqual
}
}
if !anythingUpdate {
return nil, errs.ErrNothingWillBeUpdated
}
keyProfileUpdated, err := a.users.UpdateUser(userNew, modifyUserLanguage)
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
if emailSetToUnverified {
user.EmailVerified = false
}
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid)
resp := &models.UserProfileUpdateResponse{
User: user.ToUserBasicInfo(),
User: a.GetUserBasicInfo(user),
}
if emailSetToUnverified && a.CurrentConfig().EnableUserVerifyEmail && a.CurrentConfig().EnableSMTP {
err = a.tokens.DeleteTokensByType(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY)
if err != nil {
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
} else {
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to create email verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
} else {
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserUpdateProfileHandler] cannot send verify email to \"%s\", because %s", user.Email, err.Error())
}
}()
}
}
}
if keyProfileUpdated {
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(uid, now)
err = a.tokens.DeleteTokensBeforeTime(c, uid, now)
if err == nil {
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
log.Infof(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
token, claims, err := a.tokens.CreateToken(user, c)
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
log.Warnf(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return resp, nil
}
resp.NewToken = token
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
return resp, nil
}
return resp, nil
}
// UserUpdateAvatarHandler saves user avatar by request parameters for current user
func (a *UsersApi) UserUpdateAvatarHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get multi-part form data for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrParameterInvalid
}
avatarFiles := form.File["avatar"]
if len(avatarFiles) < 1 {
log.Warnf(c, "[users.UserUpdateAvatarHandler] there is no user avatar in request for user \"uid:%d\"", user.Uid)
return nil, errs.ErrNoUserAvatar
}
if avatarFiles[0].Size < 1 {
log.Warnf(c, "[users.UserUpdateAvatarHandler] the size of user avatar in request is zero for user \"uid:%d\"", user.Uid)
return nil, errs.ErrUserAvatarIsEmpty
}
if avatarFiles[0].Size > int64(a.CurrentConfig().MaxAvatarFileSize) {
log.Warnf(c, "[users.UserUpdateAvatarHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of user avatar for user \"uid:%d\"", avatarFiles[0].Size, a.CurrentConfig().MaxAvatarFileSize, uid)
return nil, errs.ErrExceedMaxUserAvatarFileSize
}
fileExtension := utils.GetFileNameExtension(avatarFiles[0].Filename)
if utils.GetImageContentType(fileExtension) == "" {
log.Warnf(c, "[users.UserUpdateAvatarHandler] the file extension \"%s\" of user avatar in request is not supported for user \"uid:%d\"", fileExtension, user.Uid)
return nil, errs.ErrImageTypeNotSupported
}
avatarFile, err := avatarFiles[0].Open()
if err != nil {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to get avatar file from request for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrOperationFailed
}
err = a.users.UpdateUserAvatar(c, user.Uid, avatarFile, fileExtension, user.CustomAvatarType)
if err != nil {
log.Errorf(c, "[users.UserUpdateAvatarHandler] failed to update avatar for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
user.CustomAvatarType = fileExtension
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserRemoveAvatarHandler removes user avatar by request parameters for current user
func (a *UsersApi) UserRemoveAvatarHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
if user.CustomAvatarType == "" {
return nil, errs.ErrNothingWillBeUpdated
}
err = a.users.RemoveUserAvatar(c, user.Uid, user.CustomAvatarType)
if err != nil {
log.Errorf(c, "[users.UserRemoveAvatarHandler] failed to remove avatar for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
user.CustomAvatarType = ""
userResp := a.getUserProfileResponse(user)
return userResp, nil
}
// UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserVerifyEmail {
return nil, errs.ErrEmailValidationNotAllowed
}
var userResendVerifyEmailReq models.UserResendVerifyEmailRequest
err := c.ShouldBindJSON(&userResendVerifyEmailReq)
user, err := a.users.GetUserByEmail(c, userResendVerifyEmailReq.Email)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(userResendVerifyEmailReq.Password, user) {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password")
return nil, errs.ErrUserPasswordWrong
}
if user.Disabled {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
return true, nil
}
// UserSendVerifyEmailByLoginedUserHandler sends logined user verify email
func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.WebContext) (any, *errs.Error) {
if !a.CurrentConfig().EnableUserVerifyEmail {
return nil, errs.ErrEmailValidationNotAllowed
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.EmailVerified {
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
if !a.CurrentConfig().EnableSMTP {
return nil, errs.ErrSMTPServerNotEnabled
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.Errorf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
go func() {
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.Warnf(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
}
}()
return true, nil
}
// UserGetAvatarHandler returns user avatar data for current user
func (a *UsersApi) UserGetAvatarHandler(c *core.WebContext) ([]byte, string, *errs.Error) {
fileName := c.Param("fileName")
fileExtension := utils.GetFileNameExtension(fileName)
contentType := utils.GetImageContentType(fileExtension)
if contentType == "" {
return nil, "", errs.ErrImageTypeNotSupported
}
uid := c.GetCurrentUid()
fileBaseName := utils.GetFileNameWithoutExtension(fileName)
if utils.Int64ToString(uid) != fileBaseName {
log.Warnf(c, "[users.UserGetAvatarHandler] cannot get other user avatar \"uid:%s\" for user \"uid:%d\"", fileBaseName, uid)
return nil, "", errs.ErrUserIdInvalid
}
avatarData, err := a.users.GetUserAvatar(c, uid, fileExtension)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[users.UserGetAvatarHandler] failed to get user avatar, because %s", err.Error())
}
return nil, "", errs.Or(err, errs.ErrOperationFailed)
}
return avatarData, contentType, nil
}
func (a *UsersApi) getUserProfileResponse(user *models.User) *models.UserProfileResponse {
return user.ToUserProfileResponse(a.GetUserBasicInfo(user))
}
+8
View File
@@ -0,0 +1,8 @@
package avatars
import "github.com/mayswind/ezbookkeeping/pkg/models"
// AvatarProvider is user avatar provider interface
type AvatarProvider interface {
GetAvatarUrl(user *models.User) string
}
+39
View File
@@ -0,0 +1,39 @@
package avatars
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// AvatarProviderContainer contains the current user avatar provider
type AvatarProviderContainer struct {
Current AvatarProvider
}
// Initialize a user avatar provider container singleton instance
var (
Container = &AvatarProviderContainer{}
)
// InitializeAvatarProvider initializes the current user avatar provider according to the config
func InitializeAvatarProvider(config *settings.Config) error {
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
Container.Current = NewInternalStorageAvatarProvider(config)
return nil
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
Container.Current = NewGravatarAvatarProvider()
return nil
} else if config.AvatarProvider == "" {
Container.Current = NewNullAvatarProvider()
return nil
}
return errs.ErrInvalidAvatarProvider
}
// GetAvatarUrl returns the avatar url by the current user avatar provider
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
return p.Current.GetAvatarUrl(user)
}
+31
View File
@@ -0,0 +1,31 @@
package avatars
import (
"fmt"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// Reference: https://en.gravatar.com/site/implement/hash/
const gravatarUrlFormat = "https://www.gravatar.com/avatar/%s"
// GravatarAvatarProvider represents the gravatar avatar provider
type GravatarAvatarProvider struct {
}
// NewGravatarAvatarProvider returns a new gravatar avatar provider
func NewGravatarAvatarProvider() *GravatarAvatarProvider {
return &GravatarAvatarProvider{}
}
// GetAvatarUrl returns the gravatar url
func (p *GravatarAvatarProvider) GetAvatarUrl(user *models.User) string {
email := user.Email
email = strings.TrimSpace(email)
email = strings.ToLower(email)
emailMd5 := utils.MD5EncodeToString([]byte(email))
return fmt.Sprintf(gravatarUrlFormat, emailMd5)
}
+20
View File
@@ -0,0 +1,20 @@
package avatars
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestGravatarAvatarProvider_GetGravatarUrl(t *testing.T) {
avatarProvider := NewGravatarAvatarProvider()
expectedValue := "https://www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346"
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Email: "MyEmailAddress@example.com",
})
assert.Equal(t, expectedValue, actualValue)
}
+31
View File
@@ -0,0 +1,31 @@
package avatars
import (
"fmt"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const internalAvatarUrlFormat = "%savatar/%d.%s"
// InternalStorageAvatarProvider represents the internal storage avatar provider
type InternalStorageAvatarProvider struct {
webRootUrl string
}
// NewInternalStorageAvatarProvider returns a new internal storage avatar provider
func NewInternalStorageAvatarProvider(config *settings.Config) *InternalStorageAvatarProvider {
return &InternalStorageAvatarProvider{
webRootUrl: config.RootUrl,
}
}
// GetAvatarUrl returns the built-in avatar url
func (p *InternalStorageAvatarProvider) GetAvatarUrl(user *models.User) string {
if user.CustomAvatarType == "" {
return ""
}
return fmt.Sprintf(internalAvatarUrlFormat, p.webRootUrl, user.Uid, user.CustomAvatarType)
}
@@ -0,0 +1,38 @@
package avatars
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
func TestInternalStorageAvatarProvider_GetAvatarUrl(t *testing.T) {
avatarProvider := NewInternalStorageAvatarProvider(&settings.Config{
RootUrl: "https://foo.bar/",
})
expectedValue := "https://foo.bar/avatar/1234567890.jpg"
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Uid: 1234567890,
CustomAvatarType: "jpg",
})
assert.Equal(t, expectedValue, actualValue)
}
func TestInternalStorageAvatarProvider_GetAvatarUrl_EmptyCustomAvatarType(t *testing.T) {
avatarProvider := NewInternalStorageAvatarProvider(&settings.Config{
RootUrl: "https://foo.bar/",
})
expectedValue := ""
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Uid: 1234567890,
CustomAvatarType: "",
})
assert.Equal(t, expectedValue, actualValue)
}
+19
View File
@@ -0,0 +1,19 @@
package avatars
import (
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// NullAvatarProvider represents the null avatar provider
type NullAvatarProvider struct {
}
// NewNullAvatarProvider returns a new null avatar provider
func NewNullAvatarProvider() *NullAvatarProvider {
return &NullAvatarProvider{}
}
// GetAvatarUrl returns an empty url
func (p *NullAvatarProvider) GetAvatarUrl(user *models.User) string {
return ""
}
+20
View File
@@ -0,0 +1,20 @@
package avatars
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
func TestNullAvatarProvider_GetGravatarUrl(t *testing.T) {
avatarProvider := NewNullAvatarProvider()
expectedValue := ""
actualValue := avatarProvider.GetAvatarUrl(&models.User{
Email: "MyEmailAddress@example.com",
})
assert.Equal(t, expectedValue, actualValue)
}
+13
View File
@@ -0,0 +1,13 @@
package cli
import "github.com/mayswind/ezbookkeeping/pkg/settings"
// CliUsingConfig represents a cli that need to use config
type CliUsingConfig struct {
container *settings.ConfigContainer
}
// CurrentConfig returns the current config
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
return l.container.Current
}
+560 -129
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
package alipay
// alipayAppTransactionDataCsvFileImporter defines the structure of alipay app csv importer for transaction data
type alipayAppTransactionDataCsvFileImporter struct {
alipayTransactionDataCsvFileImporter
}
// Initialize a alipay app transaction data csv file importer singleton instance
var (
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
alipayTransactionDataCsvFileImporter{
fileHeaderLine: "------------------------------------------------------------------------------------",
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易时间",
categoryColumnName: "交易分类",
targetNameColumnName: "交易对方",
productNameColumnName: "商品说明",
amountColumnName: "金额",
typeColumnName: "收/支",
relatedAccountColumnName: "收/付款方式",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
},
},
}
)
@@ -0,0 +1,162 @@
package alipay
import (
"bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
}
var alipayTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
}
// alipayTransactionColumnNames defines the structure of alipay transaction plain text header names
type alipayTransactionColumnNames struct {
timeColumnName string
categoryColumnName string
targetNameColumnName string
productNameColumnName string
amountColumnName string
typeColumnName string
relatedAccountColumnName string
statusColumnName string
descriptionColumnName string
}
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
type alipayTransactionDataCsvFileImporter struct {
fileHeaderLine string
dataHeaderStartContent string
dataBottomEndLineRune rune
originalColumnNames alipayTransactionColumnNames
}
// ParseImportedData returns the imported data by parsing the alipay transaction csv data
func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
enc := simplifiedchinese.GB18030
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
dataTable, err := c.createNewAlipayImportedDataTable(ctx, reader, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewImportedCommonDataTable(dataTable)
if !commonDataTable.HasColumn(c.originalColumnNames.timeColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.amountColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.typeColumnName) ||
!commonDataTable.HasColumn(c.originalColumnNames.statusColumnName) {
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.ParseImportedData] cannot parse alipay csv data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionRowParser := createAlipayTransactionDataRowParser(c.originalColumnNames)
transactionDataTable := datatable.CreateNewCommonTransactionDataTable(commonDataTable, alipayTransactionSupportedColumns, transactionRowParser)
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(alipayTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *alipayTransactionDataCsvFileImporter) createNewAlipayImportedDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.ImportedDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse alipay csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], fileHeaderLine) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
continue
}
}
if !foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], dataHeaderStartContent) >= 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
} else if len(items) == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], dataBottomEndLineRune) {
break
}
for i := 0; i < len(items); i++ {
items[i] = strings.Trim(items[i], " ")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
allOriginalLines = append(allOriginalLines, items)
}
}
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
return nil, errs.ErrInvalidFileHeader
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[alipay_transaction_csv_data_table.createNewAlipayImportedDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvImportedDataTable(allOriginalLines)
return dataTable, nil
}
@@ -0,0 +1,614 @@
package alipay
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"golang.org/x/text/encoding/simplifiedchinese"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestAlipayCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,交易成功 ,\n" +
"2024-09-01 12:34:56 ,xxxx ,123.45 ,支出 ,交易成功 ,\n" +
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易成功 ,\n" +
"2024-09-02 23:59:59 ,提现-普通提现 ,0.03 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12345), allNewTransactions[1].Amount)
assert.Equal(t, "", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
assert.Equal(t, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
assert.Equal(t, int64(5), allNewTransactions[2].Amount)
assert.Equal(t, "", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Alipay", allNewTransactions[2].OriginalDestinationAccountName)
assert.Equal(t, "", 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-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
assert.Equal(t, int64(3), allNewTransactions[3].Amount)
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Alipay", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,0.12 ,不计收支 ,退款成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,0.12 ,收入 ,退税成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01T12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"09/01/2024 12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 , ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
// income to alipay wallet
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
// refund to other account
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,不计收支 ,退款成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
// transfer to alipay wallet
data3, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,充值-普通充值 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalDestinationAccountName)
// transfer from alipay wallet
data4, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,提现-实时提现 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Alipay", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
// transfer in
data5, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,xx-转入 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
// transfer out
data6, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,xx-转出 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
// repayment
data7, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,交易对方 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,test ,xx还款 ,0.12 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data7), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseCategory(t *testing.T) {
converter := AlipayAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
"导出信息:\n" +
"姓名:xxx\n" +
"支付宝账户:xxx@xxx.xxx\n" +
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
"导出交易类型:[全部]\n" +
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
"交易时间,交易分类,商品说明,收/支,金额,交易状态,\n" +
"2024-09-01 01:23:45,Test Category,xxxx,收入,0.12,交易成功,\n" +
"2024-09-01 12:34:56,Test Category2,xxxx,支出,123.45,交易成功,\n" +
"2024-09-01 23:59:59,Test Category3,充值-普通充值,不计收支,0.05,交易成功,\n")
assert.Nil(t, err)
allNewTransactions, _, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T) {
converter := AlipayAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
"导出信息:\n" +
"姓名:xxx\n" +
"支付宝账户:xxx@xxx.xxx\n" +
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
"导出交易类型:[全部]\n" +
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
"交易时间,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
"2024-09-01 03:45:07,余额宝-单次转入,不计收支,0.01,Test Account,交易成功,\n" +
"2024-09-01 05:07:29,信用卡还款,不计收支,0.02,Test Account2,交易成功,\n")
assert.Nil(t, err)
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 3, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, int64(2), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName)
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, "", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
}
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,备注 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 ,test2 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "test2", allNewTransactions[0].Comment)
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,备注 ,\n" +
"2024-09-01 12:34:56 ,test ,0.12 ,收入 ,交易成功 , ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "test", allNewTransactions[0].Comment)
}
func TestAlipayCsvFileImporterParseImportedData_SkipClosedIncomeOrTransferTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,交易关闭 ,\n" +
"2024-09-01 23:59:59 ,充值-普通充值 ,0.05 ,不计收支 ,交易关闭 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownProductTransferTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 23:59:59 ,xxxx ,0.05 ,不计收支 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,商品名称 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 01:23:45 ,xxxx ,0.12 ,收入 ,xxxx ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestAlipayCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
data, err := simplifiedchinese.GB18030.NewEncoder().String(
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
assert.Nil(t, err)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
}
func TestAlipayCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"金额(元),收/支 ,交易状态 ,\n" +
"0.12 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,收/支 ,交易状态 ,\n" +
"2024-09-01 12:34:56 ,收入 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Status Column
data3, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,收入 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
data4, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),交易状态 ,\n" +
"2024-09-01 12:34:56 ,0.12 ,交易成功 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
func TestAlipayCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
"---------------------------------交易记录明细列表------------------------------------\n" +
"交易创建时间 ,金额(元),收/支 ,交易状态 ,\n" +
"------------------------------------------------------------------------------------\n")
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
@@ -0,0 +1,178 @@
package alipay
import (
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const alipayTransactionDataStatusSuccessName = "交易成功"
const alipayTransactionDataStatusPaymentSuccessName = "支付成功"
const alipayTransactionDataStatusRepaymentSuccessName = "还款成功"
const alipayTransactionDataStatusClosedName = "交易关闭"
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
const alipayTransactionDataProductNameTransferInText = "转入"
const alipayTransactionDataProductNameTransferOutText = "转出"
const alipayTransactionDataProductNameRepaymentText = "还款"
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
type alipayTransactionDataRowParser struct {
columns alipayTransactionColumnNames
}
// Parse returns the converted transaction data row
func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataTable *datatable.CommonTransactionDataTable, dataRow datatable.CommonDataRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
if dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
dataRow.GetData(p.columns.typeColumnName) != alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because type is \"%s\"", rowId, dataRow.GetData(p.columns.typeColumnName))
return nil, false, nil
}
if dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusPaymentSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRepaymentSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusClosedName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusRefundSuccessName &&
dataRow.GetData(p.columns.statusColumnName) != alipayTransactionDataStatusTaxRefundSuccessName {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because status is \"%s\"", rowId, dataRow.GetData(p.columns.statusColumnName))
return nil, false, nil
}
data := make(map[datatable.TransactionDataTableColumn]string, len(alipayTransactionSupportedColumns))
if dataTable.HasOriginalColumn(p.columns.timeColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(p.columns.timeColumnName)
}
if dataTable.HasOriginalColumn(p.columns.categoryColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(p.columns.categoryColumnName)
} else {
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
}
if dataTable.HasOriginalColumn(p.columns.amountColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(p.columns.amountColumnName)
}
if dataTable.HasOriginalColumn(p.columns.descriptionColumnName) && dataRow.GetData(p.columns.descriptionColumnName) != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.descriptionColumnName)
} else if dataTable.HasOriginalColumn(p.columns.productNameColumnName) && dataRow.GetData(p.columns.productNameColumnName) != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(p.columns.productNameColumnName)
} else {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
}
relatedAccountName := ""
if dataTable.HasOriginalColumn(p.columns.relatedAccountColumnName) {
relatedAccountName = dataRow.GetData(p.columns.relatedAccountColumnName)
}
statusName := ""
if dataTable.HasOriginalColumn(p.columns.statusColumnName) {
statusName = dataRow.GetData(p.columns.statusColumnName)
}
locale := user.Language
if locale == "" {
locale = ctx.GetClientLocale()
}
localeTextItems := locales.GetLocaleTextItems(locale)
if dataTable.HasOriginalColumn(p.columns.typeColumnName) {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(p.columns.typeColumnName)
if dataRow.GetData(p.columns.typeColumnName) == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
if statusName == alipayTransactionDataStatusClosedName {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because income transaction is closed", rowId)
return nil, false, nil
}
if statusName == alipayTransactionDataStatusSuccessName {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
} else if dataRow.GetData(p.columns.typeColumnName) == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
if statusName == alipayTransactionDataStatusClosedName {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because non-income/expense transaction is closed", rowId)
return nil, false, nil
}
targetName := ""
productName := ""
if dataTable.HasOriginalColumn(p.columns.targetNameColumnName) {
targetName = dataRow.GetData(p.columns.targetNameColumnName)
}
if dataTable.HasOriginalColumn(p.columns.productNameColumnName) {
productName = dataRow.GetData(p.columns.productNameColumnName)
}
if statusName == alipayTransactionDataStatusRefundSuccessName {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
} else {
if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferInText) >= 0 { // transfer in
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else {
log.Warnf(ctx, "[alipay_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because product name (\"%s\") is unknown", rowId, productName)
return nil, false, nil
}
}
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
}
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] && statusName != "" {
if statusName == alipayTransactionDataStatusRefundSuccessName || statusName == alipayTransactionDataStatusTaxRefundSuccessName {
amount, err := utils.ParseAmount(data[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err == nil {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
}
}
return data, true, nil
}
// createAlipayTransactionDataRowParser returns alipay transaction data row parser
func createAlipayTransactionDataRowParser(originalColumnNames alipayTransactionColumnNames) datatable.CommonTransactionDataRowParser {
return &alipayTransactionDataRowParser{
columns: originalColumnNames,
}
}
@@ -0,0 +1,28 @@
package alipay
// alipayWebTransactionDataCsvFileImporter defines the structure of alipay (web) csv importer for transaction data
type alipayWebTransactionDataCsvFileImporter struct {
alipayTransactionDataCsvFileImporter
}
// Initialize a alipay (web) transaction data csv file importer singleton instance
var (
AlipayWebTransactionDataCsvFileImporter = &alipayWebTransactionDataCsvFileImporter{
alipayTransactionDataCsvFileImporter{
fileHeaderLine: "支付宝交易记录明细查询",
dataHeaderStartContent: "交易记录明细列表",
dataBottomEndLineRune: '-',
originalColumnNames: alipayTransactionColumnNames{
timeColumnName: "交易创建时间",
categoryColumnName: "",
targetNameColumnName: "交易对方",
productNameColumnName: "商品名称",
amountColumnName: "金额(元)",
typeColumnName: "收/支",
relatedAccountColumnName: "",
statusColumnName: "交易状态",
descriptionColumnName: "备注",
},
},
}
)
@@ -0,0 +1,197 @@
package beancount
import (
"fmt"
"strconv"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
)
var operatorPriority = map[rune]int{
'+': 1,
'-': 1,
'*': 2,
'/': 2,
}
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
finalTokens := make([]string, 0)
operatorStack := make([]rune, 0)
currentNumberBuilder := strings.Builder{}
isLastTokenOperator := true
expr = strings.ReplaceAll(expr, " ", "")
for i := 0; i < len(expr); i++ {
ch := rune(expr[i])
// number
if '0' <= ch && ch <= '9' || ch == '.' {
currentNumberBuilder.WriteRune(ch)
continue
} else if ch == '-' && i+1 < len(expr) && '0' <= expr[i+1] && expr[i+1] <= '9' && currentNumberBuilder.Len() == 0 && isLastTokenOperator {
currentNumberBuilder.WriteRune(ch)
continue
}
// operator or parenthesis
if currentNumberBuilder.Len() > 0 {
finalTokens = append(finalTokens, currentNumberBuilder.String())
currentNumberBuilder.Reset()
isLastTokenOperator = false
}
switch ch {
case '+', '-', '*', '/':
if ch == '-' && isLastTokenOperator {
currentNumberBuilder.WriteRune(ch)
continue
}
for len(operatorStack) > 0 {
topOperator := operatorStack[len(operatorStack)-1]
if topOperator == '(' {
break
}
if operatorPriority[topOperator] >= operatorPriority[ch] {
finalTokens = append(finalTokens, string(topOperator))
operatorStack = operatorStack[:len(operatorStack)-1]
} else {
break
}
}
operatorStack = append(operatorStack, ch)
isLastTokenOperator = true
case '(':
operatorStack = append(operatorStack, ch)
isLastTokenOperator = true
case ')':
hasLeftParenthesis := false
for len(operatorStack) > 0 {
topOperator := operatorStack[len(operatorStack)-1]
operatorStack = operatorStack[:len(operatorStack)-1]
if topOperator == '(' {
hasLeftParenthesis = true
break
}
finalTokens = append(finalTokens, string(topOperator))
}
if !hasLeftParenthesis {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because missing left parenthesis", expr)
return nil, errs.ErrInvalidAmountExpression
}
isLastTokenOperator = false
default:
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because containing unknown token \"%c\"", expr, ch)
return nil, errs.ErrInvalidAmountExpression
}
}
if currentNumberBuilder.Len() > 0 {
finalTokens = append(finalTokens, currentNumberBuilder.String())
}
for len(operatorStack) > 0 {
topOperator := operatorStack[len(operatorStack)-1]
operatorStack = operatorStack[:len(operatorStack)-1]
if topOperator == '(' {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.toPostfixExprTokens] cannot parse expression \"%s\", because missing right parenthesis", expr)
return nil, errs.ErrInvalidAmountExpression
}
finalTokens = append(finalTokens, string(topOperator))
}
return finalTokens, nil
}
func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
stack := make([]float64, 0)
for i := 0; i < len(tokens); i++ {
token := tokens[i]
switch token {
case "+", "-", "*", "/": // operators
if len(stack) < 2 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression
}
// pop the top two operands
b := stack[len(stack)-1]
stack = stack[:len(stack)-1]
a := stack[len(stack)-1]
stack = stack[:len(stack)-1]
// evaluate the operation
var result float64
switch token {
case "+":
result = a + b
case "-":
result = a - b
case "*":
result = a * b
case "/":
if b == 0 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression
}
result = a / b
}
// push the result back to the stack
stack = append(stack, result)
default: // operands
num, err := strconv.ParseFloat(token, 64)
if err != nil {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression
}
stack = append(stack, num)
}
}
if len(stack) != 1 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression
}
return stack[0], nil
}
func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, error) {
if expr == "" {
return "", nil
}
postfixExprTokens, err := toPostfixExprTokens(ctx, expr)
if err != nil {
return "", err
}
result, err := evaluatePostfixExpr(ctx, postfixExprTokens)
if err != nil {
return "", err
}
return fmt.Sprintf("%.2f", result), nil
}
@@ -0,0 +1,216 @@
package beancount
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestToPostfixExprTokens_ValidExpression(t *testing.T) {
context := core.NewNullContext()
result, err := toPostfixExprTokens(context, "1+2")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "+"}, result)
result, err = toPostfixExprTokens(context, "3-4")
assert.Nil(t, err)
assert.Equal(t, []string{"3", "4", "-"}, result)
result, err = toPostfixExprTokens(context, "5*6")
assert.Nil(t, err)
assert.Equal(t, []string{"5", "6", "*"}, result)
result, err = toPostfixExprTokens(context, "8/2")
assert.Nil(t, err)
assert.Equal(t, []string{"8", "2", "/"}, result)
result, err = toPostfixExprTokens(context, "1+2*3-(4/2)")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"}, result)
result, err = toPostfixExprTokens(context, "1 + 2 * 3")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "3", "*", "+"}, result)
result, err = toPostfixExprTokens(context, "-1+2")
assert.Nil(t, err)
assert.Equal(t, []string{"-1", "2", "+"}, result)
result, err = toPostfixExprTokens(context, "1.5+2.3")
assert.Nil(t, err)
assert.Equal(t, []string{"1.5", "2.3", "+"}, result)
result, err = toPostfixExprTokens(context, "(1+2)-3")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "+", "3", "-"}, result)
result, err = toPostfixExprTokens(context, "2*-3-3/-2")
assert.Nil(t, err)
assert.Equal(t, []string{"2", "-3", "*", "3", "-2", "/", "-"}, result)
result, err = toPostfixExprTokens(context, "-1.2-3.4*(-5.6/7.8*(9.0-1.2))")
assert.Nil(t, err)
assert.Equal(t, []string{"-1.2", "3.4", "-5.6", "7.8", "/", "9.0", "1.2", "-", "*", "*", "-"}, result)
result, err = toPostfixExprTokens(context, "((((((1+2)*(3+4))))))")
assert.Nil(t, err)
assert.Equal(t, []string{"1", "2", "+", "3", "4", "+", "*"}, result)
result, err = toPostfixExprTokens(context, "(((())))")
assert.Nil(t, err)
assert.Equal(t, []string{}, result)
result, err = toPostfixExprTokens(context, "+-*/")
assert.Nil(t, err)
assert.Equal(t, []string{"-", "*", "/", "+"}, result)
result, err = toPostfixExprTokens(context, "")
assert.Nil(t, err)
assert.Equal(t, []string{}, result)
}
func TestToPostfixExprTokens_InvalidExpression(t *testing.T) {
context := core.NewNullContext()
_, err := toPostfixExprTokens(context, "1=2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, "(1")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, "2)")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, "((((1+2)))")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = toPostfixExprTokens(context, ")(")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
}
func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
context := core.NewNullContext()
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
assert.Nil(t, err)
assert.Equal(t, float64(3), result)
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
assert.Nil(t, err)
assert.Equal(t, float64(2), result)
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
assert.Nil(t, err)
assert.Equal(t, float64(12), result)
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
assert.Nil(t, err)
assert.Equal(t, float64(3), result)
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
assert.Nil(t, err)
assert.Equal(t, float64(5), result)
}
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
context := core.NewNullContext()
_, err := evaluatePostfixExpr(context, []string{"1", "0", "/"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "+"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "="})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "("})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", ")"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"1", "2", "+", "3"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluatePostfixExpr(context, []string{"abc"})
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
}
func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
context := core.NewNullContext()
result, err := evaluateBeancountAmountExpression(context, "")
assert.Nil(t, err)
assert.Equal(t, "", result)
result, err = evaluateBeancountAmountExpression(context, "1+2")
assert.Nil(t, err)
assert.Equal(t, "3.00", result)
result, err = evaluateBeancountAmountExpression(context, "(1+2)*3")
assert.Nil(t, err)
assert.Equal(t, "9.00", result)
result, err = evaluateBeancountAmountExpression(context, "-1+2")
assert.Nil(t, err)
assert.Equal(t, "1.00", result)
result, err = evaluateBeancountAmountExpression(context, "1.5+2.5")
assert.Nil(t, err)
assert.Equal(t, "4.00", result)
result, err = evaluateBeancountAmountExpression(context, "1+2*3-(4/2)")
assert.Nil(t, err)
assert.Equal(t, "5.00", result)
result, err = evaluateBeancountAmountExpression(context, "2*-3-3/-2")
assert.Nil(t, err)
assert.Equal(t, "-4.50", result)
result, err = evaluateBeancountAmountExpression(context, "-1.2-3.4*(-5.6/7.8*(9.0-1.2))")
assert.Nil(t, err)
assert.Equal(t, "17.84", result)
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
assert.Nil(t, err)
assert.Equal(t, "10.00", result)
}
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
context := core.NewNullContext()
_, err := evaluateBeancountAmountExpression(context, "1++2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1^2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "+-*/")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "a+b")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1/0")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+(2*3")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+2*3)")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+((((2*3)))")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1+2(3)")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "1)*(2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
}
@@ -0,0 +1,93 @@
package beancount
import "strings"
const beancountEquityAccountNameOpeningBalance = "Opening-Balances"
// beancountDirective represents the Beancount directive
type beancountDirective string
// Beancount directives
const (
beancountDirectiveOpen beancountDirective = "open"
beancountDirectiveClose beancountDirective = "close"
beancountDirectiveTransaction beancountDirective = "txn"
beancountDirectiveCompletedTransaction beancountDirective = "*"
beancountDirectiveInCompleteTransaction beancountDirective = "!"
beancountDirectivePaddingTransaction beancountDirective = "P"
beancountDirectiveCommodity beancountDirective = "commodity"
beancountDirectivePrice beancountDirective = "price"
beancountDirectiveNote beancountDirective = "note"
beancountDirectiveDocument beancountDirective = "document"
beancountDirectiveEvent beancountDirective = "event"
beancountDirectiveBalance beancountDirective = "balance"
beancountDirectivePad beancountDirective = "pad"
beancountDirectiveQuery beancountDirective = "query"
beancountDirectiveCustom beancountDirective = "custom"
)
// beancountAccountType represents the Beancount account type
type beancountAccountType byte
// Beancount account types
const (
beancountUnknownAccountType beancountAccountType = 0
beancountAssetsAccountType beancountAccountType = 1
beancountLiabilitiesAccountType beancountAccountType = 2
beancountEquityAccountType beancountAccountType = 3
beancountIncomeAccountType beancountAccountType = 4
beancountExpensesAccountType beancountAccountType = 5
)
// beancountData defines the structure of beancount data
type beancountData struct {
accounts map[string]*beancountAccount
transactions []*beancountTransactionEntry
}
// beancountAccount defines the structure of beancount account
type beancountAccount struct {
name string
accountType beancountAccountType
openDate string
closeDate string
}
// beancountTransactionEntry defines the structure of beancount transaction entry
type beancountTransactionEntry struct {
date string
directive beancountDirective
payee string
narration string
postings []*beancountPosting
tags []string
links []string
metadata map[string]string
}
// beancountPosting defines the structure of beancount transaction posting
type beancountPosting struct {
account string
amount string
originalAmount string
commodity string
totalCost string
totalCostCommodity string
price string
priceCommodity string
metadata map[string]string
}
func (a *beancountAccount) isOpeningBalanceEquityAccount() bool {
if a.accountType != beancountEquityAccountType {
return false
}
nameItems := strings.Split(a.name, string(beancountMetadataKeySuffix))
if len(nameItems) != 2 {
return false
}
return nameItems[1] == beancountEquityAccountNameOpeningBalance
}
@@ -0,0 +1,655 @@
package beancount
import (
"bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const beancountDefaultAssetsAccountTypeName = "Assets"
const beancountDefaultLiabilitiesAccountTypeName = "Liabilities"
const beancountDefaultEquityAccountTypeName = "Equity"
const beancountDefaultIncomeAccountTypeName = "Income"
const beancountDefaultExpenseAccountTypeName = "Expenses"
const beancountOptionAssetsAccountTypeName = "name_assets"
const beancountOptionLiabilitiesAccountTypeName = "name_liabilities"
const beancountOptionEquityAccountTypeName = "name_equity"
const beancountOptionIncomeAccountTypeName = "name_income"
const beancountOptionExpenseAccountTypeName = "name_expenses"
const beancountCommentPrefix = ';'
const beancountAccountNameItemsSeparator = ":"
const beancountMetadataKeySuffix = ':'
const beancountPricePrefix = '@'
const beancountLinkPrefix = '^'
const beancountTagPrefix = '#'
// beancountDataReader defines the structure of Beancount data reader
type beancountDataReader struct {
accountTypeNameMap map[string]beancountAccountType
accountTypeNameReversedMap map[beancountAccountType]string
allData [][]string
}
// read returns the imported Beancount data
// Reference: https://beancount.github.io/docs/beancount_language_syntax.html
func (r *beancountDataReader) read(ctx core.Context) (*beancountData, error) {
if len(r.allData) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
data := &beancountData{
accounts: make(map[string]*beancountAccount),
transactions: make([]*beancountTransactionEntry, 0),
}
var err error
var currentTransactionEntry *beancountTransactionEntry
var currentTransactionPosting *beancountPosting
var currentTags []string
for i := 0; i < len(r.allData); i++ {
items := r.allData[i]
if len(items) == 0 || (len(items) == 1 && len(items[0]) == 0) || (len(r.getNotEmptyItemByIndex(items, 0)) > 0 && r.getNotEmptyItemByIndex(items, 0)[0] == beancountCommentPrefix) { // skip empty or comment lines
continue
}
if r.getNotEmptyItemsCount(items) < 2 {
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because not enough items in line", i, strings.Join(items, " "))
continue
}
firstItem := items[0]
if firstItem == "include" { // not support include directive
return nil, errs.ErrBeancountFileNotSupportInclude
} else if firstItem == "plugin" { // skip plugin directive lines
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
continue
} else if firstItem == "option" {
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
r.readAndSetOption(ctx, i, items)
continue
} else if firstItem == "pushtag" {
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
currentTags = r.readAndSetTags(ctx, i, items, currentTags, true)
continue
} else if firstItem == "poptag" {
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
currentTags = r.readAndSetTags(ctx, i, items, currentTags, false)
continue
}
if len(firstItem) == 0 { // original line has space prefix, maybe transaction posting or metadata line
actualFirstItem := r.getNotEmptyItemByIndex(items, 0)
if len(actualFirstItem) == 0 { // skip empty lines
continue
}
if ('A' <= actualFirstItem[0] && actualFirstItem[0] <= 'Z') || actualFirstItem[0] == '!' { // transaction posting
if currentTransactionEntry != nil && currentTransactionPosting != nil {
currentTransactionEntry.postings = append(currentTransactionEntry.postings, currentTransactionPosting)
currentTransactionPosting = nil
}
currentTransactionPosting, err = r.readTransactionPostingLine(ctx, i, items, data, actualFirstItem[0] == '!')
if err != nil {
return nil, err
}
} else if 'a' <= actualFirstItem[0] && actualFirstItem[0] <= 'z' { // metadata
metadata := r.readTransactionMetadataLine(ctx, i, items)
if metadata == nil {
continue
}
metadataKey := metadata[0]
metadataValue := metadata[1]
if currentTransactionPosting != nil {
if _, exists := currentTransactionPosting.metadata[metadataKey]; !exists {
currentTransactionPosting.metadata[metadataKey] = metadataValue
}
} else if currentTransactionEntry != nil {
if _, exists := currentTransactionEntry.metadata[metadataKey]; !exists {
currentTransactionEntry.metadata[metadataKey] = metadataValue
}
}
} else {
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because line prefix is invalid", i, strings.Join(items, " "))
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
continue
}
} else if _, err := utils.ParseFromLongDateFirstTime(firstItem, 0); err == nil { // original line has date as first item
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
directive := r.getNotEmptyItemByIndex(items, 1)
if directive == string(beancountDirectiveOpen) ||
directive == string(beancountDirectiveClose) {
_, err := r.readAccountLine(ctx, i, items, firstItem, beancountDirective(directive), data)
if err != nil {
return nil, err
}
} else if directive == string(beancountDirectiveTransaction) ||
directive == string(beancountDirectiveCompletedTransaction) ||
directive == string(beancountDirectiveInCompleteTransaction) ||
directive == string(beancountDirectivePaddingTransaction) {
currentTransactionEntry = r.readTransactionLine(ctx, i, items, firstItem, beancountDirective(directive), currentTags)
} else if directive == string(beancountDirectiveCommodity) ||
directive == string(beancountDirectivePrice) ||
directive == string(beancountDirectiveNote) ||
directive == string(beancountDirectiveDocument) ||
directive == string(beancountDirectiveEvent) ||
directive == string(beancountDirectiveBalance) ||
directive == string(beancountDirectivePad) ||
directive == string(beancountDirectiveQuery) ||
directive == string(beancountDirectiveCustom) { // skip commodity / price / note / document / event / balance / pad / query / custom lines
continue
} else {
log.Warnf(ctx, "[beancount_data_reader.read] cannot parse line#%d \"%s\", because directive is unknown", i, strings.Join(items, " "))
continue
}
} else { // first item not start with date or space
currentTransactionEntry, currentTransactionPosting = r.updateCurrentState(data, currentTransactionEntry, currentTransactionPosting)
continue
}
}
if currentTransactionEntry != nil {
if currentTransactionPosting != nil {
currentTransactionEntry.postings = append(currentTransactionEntry.postings, currentTransactionPosting)
currentTransactionPosting = nil
}
data.transactions = append(data.transactions, currentTransactionEntry)
currentTransactionEntry = nil
}
return data, nil
}
func (r *beancountDataReader) updateCurrentState(data *beancountData, currentTransactionEntry *beancountTransactionEntry, currentTransactionPosting *beancountPosting) (*beancountTransactionEntry, *beancountPosting) {
if currentTransactionEntry != nil {
if currentTransactionPosting != nil {
currentTransactionEntry.postings = append(currentTransactionEntry.postings, currentTransactionPosting)
currentTransactionPosting = nil
}
data.transactions = append(data.transactions, currentTransactionEntry)
currentTransactionEntry = nil
currentTransactionPosting = nil
}
return currentTransactionEntry, currentTransactionPosting
}
func (r *beancountDataReader) readAndSetOption(ctx core.Context, lineIndex int, items []string) {
if r.getNotEmptyItemsCount(items) != 3 {
log.Warnf(ctx, "[beancount_data_reader.readAndSetOption] cannot parse account type name option line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return
}
optionName := r.getNotEmptyItemByIndex(items, 1)
optionValue := r.getNotEmptyItemByIndex(items, 2)
switch optionName {
case beancountOptionAssetsAccountTypeName:
r.setAccountTypeNameMap(beancountAssetsAccountType, optionValue)
break
case beancountOptionLiabilitiesAccountTypeName:
r.setAccountTypeNameMap(beancountLiabilitiesAccountType, optionValue)
break
case beancountOptionEquityAccountTypeName:
r.setAccountTypeNameMap(beancountEquityAccountType, optionValue)
break
case beancountOptionIncomeAccountTypeName:
r.setAccountTypeNameMap(beancountIncomeAccountType, optionValue)
break
case beancountOptionExpenseAccountTypeName:
r.setAccountTypeNameMap(beancountExpensesAccountType, optionValue)
break
default:
log.Warnf(ctx, "[beancount_data_reader.readAndSetOption] skip option line#%d \"%s\"", lineIndex, strings.Join(items, " "))
break
}
}
func (r *beancountDataReader) readAndSetTags(ctx core.Context, lineIndex int, items []string, currentTags []string, pushTag bool) []string {
if r.getNotEmptyItemsCount(items) != 2 {
log.Warnf(ctx, "[beancount_data_reader.readAndSetTags] cannot parse push/pop tag line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return currentTags
}
tag := r.getNotEmptyItemByIndex(items, 1)
if len(tag) < 2 || tag[0] != beancountTagPrefix {
log.Warnf(ctx, "[beancount_data_reader.readAndSetTags] cannot parse push/pop tag line#%d \"%s\", because tag is invalid", lineIndex, strings.Join(items, " "))
return currentTags
}
tag = tag[1:]
if pushTag {
for i := 0; i < len(currentTags); i++ {
if currentTags[i] == tag {
return currentTags
}
}
return append(currentTags, tag)
} else { // pop tag
for i := 0; i < len(currentTags); i++ {
if currentTags[i] == tag {
return append(currentTags[:i], currentTags[i+1:]...)
}
}
return currentTags
}
}
func (r *beancountDataReader) setAccountTypeNameMap(accountType beancountAccountType, accountTypeName string) {
delete(r.accountTypeNameMap, r.accountTypeNameReversedMap[accountType])
r.accountTypeNameMap[accountTypeName] = accountType
r.accountTypeNameReversedMap[accountType] = accountTypeName
}
func (r *beancountDataReader) readAccountLine(ctx core.Context, lineIndex int, items []string, date string, directive beancountDirective, data *beancountData) (*beancountAccount, error) {
if r.getNotEmptyItemsCount(items) < 3 {
log.Warnf(ctx, "[beancount_data_reader.parseAccount] cannot parse account line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return nil, nil
}
var err error
accountName := r.getNotEmptyItemByIndex(items, 2)
account, exists := data.accounts[accountName]
if !exists {
account, err = r.createAccount(ctx, data, accountName)
if err != nil {
return nil, err
}
}
if directive == beancountDirectiveOpen {
account.openDate = date
return account, nil
} else if directive == beancountDirectiveClose {
account.closeDate = date
return account, nil
} else {
log.Warnf(ctx, "[beancount_data_reader.parseAccount] cannot parse account line#%d \"%s\", because directive is invalid", lineIndex, strings.Join(items, " "))
return nil, nil
}
}
func (r *beancountDataReader) createAccount(ctx core.Context, data *beancountData, accountName string) (*beancountAccount, error) {
account := &beancountAccount{
name: accountName,
accountType: beancountUnknownAccountType,
}
accountNameItems := strings.Split(accountName, beancountAccountNameItemsSeparator)
if len(accountNameItems) > 1 {
accountType, exists := r.accountTypeNameMap[accountNameItems[0]]
if exists {
account.accountType = accountType
} else {
log.Warnf(ctx, "[beancount_data_reader.createAccount] cannot parse account \"%s\", because account type \"%s\" is invalid", accountName, accountNameItems[0])
return nil, errs.ErrInvalidBeancountFile
}
}
data.accounts[accountName] = account
return account, nil
}
func (r *beancountDataReader) readTransactionLine(ctx core.Context, lineIndex int, items []string, date string, directive beancountDirective, tags []string) *beancountTransactionEntry {
transactionEntry := &beancountTransactionEntry{
date: date,
directive: directive,
tags: make([]string, 0),
links: make([]string, 0),
metadata: make(map[string]string),
}
transactionEntry.tags = append(transactionEntry.tags, tags...)
allTags := make(map[string]bool, len(transactionEntry.tags))
for _, tag := range transactionEntry.tags {
allTags[tag] = true
}
// YYYY-MM-DD [txn|Flag] [[Payee] Narration] [#tag] [ˆlink]
payeeNarrationFirstIndex := 2
payeeNarrationLastIndex := len(items) - 1
// parse remain items
for i := payeeNarrationFirstIndex; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
if item[0] == beancountCommentPrefix { // ; comment
if i-1 < payeeNarrationLastIndex {
payeeNarrationLastIndex = i - 1
}
break
}
if item[0] == beancountTagPrefix { // [#tag]
tagName := item[1:]
if _, exists := allTags[tagName]; !exists {
transactionEntry.tags = append(transactionEntry.tags, tagName)
allTags[tagName] = true
}
if i-1 < payeeNarrationLastIndex {
payeeNarrationLastIndex = i - 1
}
} else if item[0] == beancountLinkPrefix { // [ˆlink]
transactionEntry.links = append(transactionEntry.links, item[1:])
if i-1 < payeeNarrationLastIndex {
payeeNarrationLastIndex = i - 1
}
}
}
if payeeNarrationLastIndex-payeeNarrationFirstIndex >= 1 {
transactionEntry.payee = items[payeeNarrationFirstIndex]
transactionEntry.narration = items[payeeNarrationFirstIndex+1]
} else if payeeNarrationLastIndex-payeeNarrationFirstIndex >= 0 {
transactionEntry.narration = items[payeeNarrationFirstIndex]
}
return transactionEntry
}
func (r *beancountDataReader) readTransactionPostingLine(ctx core.Context, lineIndex int, items []string, data *beancountData, hasFlag bool) (*beancountPosting, error) {
// [Flag] Account Amount [{Cost}] [@ Price]
accountNameExpectedIndex := 0
if hasFlag {
accountNameExpectedIndex = 1
}
if r.getNotEmptyItemsCount(items) <= accountNameExpectedIndex {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because items count in line not correct", lineIndex, strings.Join(items, " "))
return nil, nil
}
accountName, accountNameActualIndex := r.getNotEmptyItemAndIndexByIndex(items, accountNameExpectedIndex)
if accountName == "" || accountNameActualIndex < 0 {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing account name", lineIndex, strings.Join(items, " "))
return nil, errs.ErrMissingAccountData
}
transactionPositing := &beancountPosting{
account: accountName,
metadata: make(map[string]string),
}
amountActualLastIndex := -1
transactionPositing.originalAmount, amountActualLastIndex = r.getOriginalAmountAndLastIndexFromIndex(items, accountNameActualIndex+1)
if transactionPositing.originalAmount == "" || amountActualLastIndex < 0 {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing amount", lineIndex, strings.Join(items, " "))
return nil, errs.ErrAmountInvalid
}
finalAmount, err := evaluateBeancountAmountExpression(ctx, transactionPositing.originalAmount)
if err != nil {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot evaluate amount expression in line#%d \"%s\", because %s", lineIndex, strings.Join(items, " "), err.Error())
return nil, errs.ErrAmountInvalid
} else {
transactionPositing.amount = finalAmount
}
commodityActualIndex := -1
transactionPositing.commodity, commodityActualIndex = r.getNotEmptyItemAndIndexFromIndex(items, amountActualLastIndex+1)
if transactionPositing.commodity == "" || commodityActualIndex < 0 {
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because missing commodity", lineIndex, strings.Join(items, " "))
return nil, errs.ErrInvalidBeancountFile
}
if strings.ToUpper(transactionPositing.commodity) != transactionPositing.commodity { // The syntax for a currency is a word all in capital letters
log.Warnf(ctx, "[beancount_data_reader.readTransactionPostingLine] cannot parse transaction posting line#%d \"%s\", because commodity name is not capital letters", lineIndex, strings.Join(items, " "))
return nil, errs.ErrInvalidBeancountFile
}
// parse remain items
if commodityActualIndex > 0 {
for i := commodityActualIndex + 1; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
if item[0] == beancountCommentPrefix { // ; comment
break
}
if len(item) == 2 && item[0] == beancountPricePrefix && item[1] == beancountPricePrefix { // [@@ TotalCost]
totalCost, totalCostActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, i+1)
if totalCostActualIndex > 0 {
transactionPositing.totalCost = totalCost
i = totalCostActualIndex
totalCostCommodity, totalCostCommodityActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, totalCostActualIndex+1)
if totalCostCommodityActualIndex > 0 {
transactionPositing.totalCostCommodity = totalCostCommodity
i = totalCostCommodityActualIndex
}
}
} else if len(item) == 1 && item[0] == beancountPricePrefix { // [@ Price]
price, priceActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, i+1)
if priceActualIndex > 0 {
transactionPositing.price = price
i = priceActualIndex
priceCommodity, priceCommodityActualIndex := r.getNotEmptyItemAndIndexFromIndex(items, priceActualIndex+1)
if priceCommodityActualIndex > 0 {
transactionPositing.priceCommodity = priceCommodity
i = priceCommodityActualIndex
}
}
}
}
}
if transactionPositing.account != "" {
_, exists := data.accounts[transactionPositing.account]
if !exists {
_, err := r.createAccount(ctx, data, transactionPositing.account)
if err != nil {
return nil, err
}
}
}
return transactionPositing, nil
}
func (r *beancountDataReader) readTransactionMetadataLine(ctx core.Context, lineIndex int, items []string) []string {
key := r.getNotEmptyItemByIndex(items, 0)
value := r.getNotEmptyItemByIndex(items, 1)
if key == "" || value == "" {
log.Warnf(ctx, "[beancount_data_reader.readTransactionMetadataLine] cannot parse metadata line#%d \"%s\", because key or value is empty", lineIndex, strings.Join(items, " "))
return nil
}
if len(key) == 0 || key[len(key)-1] != beancountMetadataKeySuffix {
log.Warnf(ctx, "[beancount_data_reader.readTransactionMetadataLine] cannot parse metadata line#%d \"%s\", because key is invalid correct", lineIndex, strings.Join(items, " "))
return nil
}
key = key[:len(key)-1]
return []string{key, value}
}
func (r *beancountDataReader) getNotEmptyItemByIndex(items []string, index int) string {
item, _ := r.getNotEmptyItemAndIndexByIndex(items, index)
return item
}
func (r *beancountDataReader) getNotEmptyItemAndIndexByIndex(items []string, index int) (string, int) {
count := -1
for i := 0; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
count++
if count == index {
return items[i], i
}
}
return "", -1
}
func (r *beancountDataReader) getNotEmptyItemAndIndexFromIndex(items []string, startIndex int) (string, int) {
for i := startIndex; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
return item, i
}
return "", -1
}
func (r *beancountDataReader) getNotEmptyItemsCount(items []string) int {
count := 0
for i := 0; i < len(items); i++ {
if len(items[i]) > 0 {
count++
}
}
return count
}
func (r *beancountDataReader) getOriginalAmountAndLastIndexFromIndex(items []string, startIndex int) (string, int) {
amountBuilder := strings.Builder{}
lastIndex := -1
for i := startIndex; i < len(items); i++ {
item := items[i]
if len(item) == 0 {
continue
}
valid := true
// The Amount in “Postings” can also be an arithmetic expression using ( ) * / - +
for j := 0; j < len(item); j++ {
if !(item[j] >= '0' && item[j] <= '9') && item[j] != '.' && item[j] != '(' && item[j] != ')' &&
item[j] != '*' && item[j] != '/' && item[j] != '-' && item[j] != '+' {
valid = false
break
}
}
if !valid {
break
}
if amountBuilder.Len() > 0 {
amountBuilder.WriteRune(' ')
}
amountBuilder.WriteString(item)
lastIndex = i
}
return amountBuilder.String(), lastIndex
}
func createNewBeancountDataReader(ctx core.Context, data []byte) (*beancountDataReader, error) {
fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
csvReader := csv.NewReader(reader)
csvReader.Comma = ' '
csvReader.FieldsPerRecord = -1
allData := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[beancount_data_reader.createNewBeancountDataReader] cannot parse data, because %s", err.Error())
return nil, errs.ErrInvalidBeancountFile
}
allData = append(allData, items)
}
return &beancountDataReader{
accountTypeNameMap: map[string]beancountAccountType{
beancountDefaultAssetsAccountTypeName: beancountAssetsAccountType,
beancountDefaultLiabilitiesAccountTypeName: beancountLiabilitiesAccountType,
beancountDefaultEquityAccountTypeName: beancountEquityAccountType,
beancountDefaultIncomeAccountTypeName: beancountIncomeAccountType,
beancountDefaultExpenseAccountTypeName: beancountExpensesAccountType,
},
accountTypeNameReversedMap: map[beancountAccountType]string{
beancountAssetsAccountType: beancountDefaultAssetsAccountTypeName,
beancountLiabilitiesAccountType: beancountDefaultLiabilitiesAccountTypeName,
beancountEquityAccountType: beancountDefaultEquityAccountTypeName,
beancountIncomeAccountType: beancountDefaultIncomeAccountTypeName,
beancountExpensesAccountType: beancountDefaultExpenseAccountTypeName,
},
allData: allData,
}, nil
}
@@ -0,0 +1,520 @@
package beancount
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestBeancountDataReaderRead(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"; Test Beancount Data\n"+
"option \"name_assets\" \"AssetsAccount\"\n"+
"option \"name_liabilities\" \"LiabilitiesAccount\"\n"+
"option \"name_equity\" \"EquityAccount\"\n"+
"option \"name_income\" \"IncomeAccount\"\n"+
"option \"name_expenses\" \"ExpensesAccount\"\n"+
"\n"+
"2024-01-01 open AssetsAccount:TestAccount\n"+
"2024-01-02 open LiabilitiesAccount:TestAccount2\n"+
"2024-01-03 open EquityAccount:Opening-Balances\n"+
"\n"+
"; The following transactions with tag1 and tag2\n"+
"pushtag #tag1\n"+
"pushtag #tag2\n"+
"\n"+
"2024-01-05 * \"Payee Name\" \"Foo Bar\" #tag3 #tag4 ^test-link\n"+
" IncomeAccount:TestCategory -123.45 CNY\n"+
" AssetsAccount:TestAccount 123.45 CNY\n"+
"; The following transactions with tag2\n"+
"poptag #tag1\n"+
"2024-01-06 * \"test\n#test2\" #tag5 #tag6 ^test-link2\n"+
" LiabilitiesAccount:TestAccount2 -0.12 USD\n"+
" ExpensesAccount:TestCategory2 0.12 USD\n"+
"2024-01-07 close AssetsAccount:TestAccount\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 5, len(actualData.accounts))
assert.Equal(t, "AssetsAccount:TestAccount", actualData.accounts["AssetsAccount:TestAccount"].name)
assert.Equal(t, beancountAssetsAccountType, actualData.accounts["AssetsAccount:TestAccount"].accountType)
assert.Equal(t, "2024-01-01", actualData.accounts["AssetsAccount:TestAccount"].openDate)
assert.Equal(t, "2024-01-07", actualData.accounts["AssetsAccount:TestAccount"].closeDate)
assert.Equal(t, "LiabilitiesAccount:TestAccount2", actualData.accounts["LiabilitiesAccount:TestAccount2"].name)
assert.Equal(t, beancountLiabilitiesAccountType, actualData.accounts["LiabilitiesAccount:TestAccount2"].accountType)
assert.Equal(t, "2024-01-02", actualData.accounts["LiabilitiesAccount:TestAccount2"].openDate)
assert.Equal(t, 2, len(actualData.transactions))
assert.Equal(t, "2024-01-05", actualData.transactions[0].date)
assert.Equal(t, "Payee Name", actualData.transactions[0].payee)
assert.Equal(t, "Foo Bar", actualData.transactions[0].narration)
assert.Equal(t, 2, len(actualData.transactions[0].postings))
assert.Equal(t, "IncomeAccount:TestCategory", actualData.transactions[0].postings[0].account)
assert.Equal(t, "-123.45", actualData.transactions[0].postings[0].amount)
assert.Equal(t, "CNY", actualData.transactions[0].postings[0].commodity)
assert.Equal(t, "AssetsAccount:TestAccount", actualData.transactions[0].postings[1].account)
assert.Equal(t, "123.45", actualData.transactions[0].postings[1].amount)
assert.Equal(t, "CNY", actualData.transactions[0].postings[1].commodity)
assert.Equal(t, 4, len(actualData.transactions[0].tags))
assert.Equal(t, actualData.transactions[0].tags[0], "tag1")
assert.Equal(t, actualData.transactions[0].tags[1], "tag2")
assert.Equal(t, actualData.transactions[0].tags[2], "tag3")
assert.Equal(t, actualData.transactions[0].tags[3], "tag4")
assert.Equal(t, 1, len(actualData.transactions[0].links))
assert.Equal(t, actualData.transactions[0].links[0], "test-link")
assert.Equal(t, "2024-01-06", actualData.transactions[1].date)
assert.Equal(t, "", actualData.transactions[1].payee)
assert.Equal(t, "test\n#test2", actualData.transactions[1].narration)
assert.Equal(t, 2, len(actualData.transactions[1].postings))
assert.Equal(t, "LiabilitiesAccount:TestAccount2", actualData.transactions[1].postings[0].account)
assert.Equal(t, "-0.12", actualData.transactions[1].postings[0].amount)
assert.Equal(t, "USD", actualData.transactions[1].postings[0].commodity)
assert.Equal(t, "ExpensesAccount:TestCategory2", actualData.transactions[1].postings[1].account)
assert.Equal(t, "0.12", actualData.transactions[1].postings[1].amount)
assert.Equal(t, "USD", actualData.transactions[1].postings[1].commodity)
assert.Equal(t, 3, len(actualData.transactions[1].tags))
assert.Equal(t, actualData.transactions[1].tags[0], "tag2")
assert.Equal(t, actualData.transactions[1].tags[1], "tag5")
assert.Equal(t, actualData.transactions[1].tags[2], "tag6")
assert.Equal(t, 1, len(actualData.transactions[1].links))
assert.Equal(t, actualData.transactions[1].links[0], "test-link2")
}
func TestBeancountDataReaderRead_EmptyContent(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestBeancountDataReaderRead_UnsupportedInclude(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte("include \"other.beancount\""))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrBeancountFileNotSupportInclude.Message)
}
func TestBeancountDataReaderRead_SkipUnsupportedDirective(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"plugin \"beancount.plugins.plugin_name\"\n"+
"unknown directive\n"+
"2024-01-01 commodity USD\n"+
"2024-01-01 price USD 1.08 CAD\n"+
"2024-01-01 note Assets:Test \"some text\"\n"+
"2024-01-01 document Assets:Test \"scheme://path\"\n"+
"2024-01-01 event \"location\" \"address\"\n"+
"2024-01-01 balance Assets:Test 100.00 USD\n"+
"2024-01-01 pad Assets:Test Equity:Opening-Balances\n"+
"2024-01-01 query \"Name\" \"\nSELECT FIELDS FROM TABLE\"\n"+
"2024-01-01 custom \"Type\" \"Value\"\n"+
"2024-01-01 unknown directive\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.Nil(t, err)
}
func TestBeancountDataReaderReadAndSetOption_AccountTypeName(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"option \"name_assets\" \"A\"\n"+
"option \"name_liabilities\" \"L\"\n"+
"option \"name_equity\" \"E\"\n"+
"\n"+
"2024-01-01 open A:TestAccount\n"+
"2024-01-02 open L:TestAccount2\n"+
"2024-01-03 open E:Opening-Balances\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 3, len(actualData.accounts))
assert.Equal(t, "A:TestAccount", actualData.accounts["A:TestAccount"].name)
assert.Equal(t, beancountAssetsAccountType, actualData.accounts["A:TestAccount"].accountType)
assert.Equal(t, "L:TestAccount2", actualData.accounts["L:TestAccount2"].name)
assert.Equal(t, beancountLiabilitiesAccountType, actualData.accounts["L:TestAccount2"].accountType)
assert.Equal(t, "E:Opening-Balances", actualData.accounts["E:Opening-Balances"].name)
assert.Equal(t, beancountEquityAccountType, actualData.accounts["E:Opening-Balances"].accountType)
assert.True(t, actualData.accounts["E:Opening-Balances"].isOpeningBalanceEquityAccount())
}
func TestBeancountDataReaderReadAndSetOption_InvalidLineOrUnsupportedOption(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"option \"test\" \"Test\" \"Test2\"\n"+
"option \"test\" \"Test\"\n"+
"option \"test\"\n"+
"option \n"+
"option\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.Nil(t, err)
}
func TestBeancountDataReaderReadAndSetTags(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"pushtag #tag1\n"+
"pushtag #tag2\n"+
"pushtag #tag2\n"+
"pushtag #tag1\n"+
"\n"+
"2024-01-01 * #tag3 #tag4\n"+
"poptag #tag1\n"+
"poptag #tag2\n"+
"pushtag\n"+
"pushtag \n"+
"pushtag tag\n"+
"2024-01-02 * #tag5 #tag6\n"+
"poptag #tag1\n"+
"poptag #tag2\n"+
"poptag\n"+
"poptag \n"+
"2024-01-03 * #tag5 #tag6\n"+
"pushtag #tag3\n"+
"pushtag #tag6\n"+
"2024-01-04 * #tag5 #tag6\n"+
"2024-01-05 * #tag5 #tag6 #tag6 #tag5\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 5, len(actualData.transactions))
assert.Equal(t, 4, len(actualData.transactions[0].tags))
assert.Equal(t, actualData.transactions[0].tags[0], "tag1")
assert.Equal(t, actualData.transactions[0].tags[1], "tag2")
assert.Equal(t, actualData.transactions[0].tags[2], "tag3")
assert.Equal(t, actualData.transactions[0].tags[3], "tag4")
assert.Equal(t, 2, len(actualData.transactions[1].tags))
assert.Equal(t, actualData.transactions[1].tags[0], "tag5")
assert.Equal(t, actualData.transactions[1].tags[1], "tag6")
assert.Equal(t, 2, len(actualData.transactions[2].tags))
assert.Equal(t, actualData.transactions[2].tags[0], "tag5")
assert.Equal(t, actualData.transactions[2].tags[1], "tag6")
assert.Equal(t, 3, len(actualData.transactions[3].tags))
assert.Equal(t, actualData.transactions[3].tags[0], "tag3")
assert.Equal(t, actualData.transactions[3].tags[1], "tag6")
assert.Equal(t, actualData.transactions[3].tags[2], "tag5")
assert.Equal(t, 3, len(actualData.transactions[4].tags))
assert.Equal(t, actualData.transactions[4].tags[0], "tag3")
assert.Equal(t, actualData.transactions[4].tags[1], "tag6")
assert.Equal(t, actualData.transactions[4].tags[2], "tag5")
}
func TestBeancountDataReaderReadAccountLine_InvalidLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 open\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 0, len(actualData.accounts))
}
func TestBeancountDataReaderReadAccountLine_InvalidAccountType(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 open Test:TestAccount\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
reader, err = createNewBeancountDataReader(context, []byte(""+
"option \"name_assets\" \"A\"\n"+
"\n"+
"2024-01-01 open Assets:TestAccount\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
"2024-01-02 * \"test\ttest2\ntest3\" ; some comment\n"+
"2024-01-03 ! \"test\" \"test2\"\n"+
"2024-01-04 P \"test\" #tag #tag2 ; some comment\n"+
"2024-01-05 txn \"test\" ^scheme://path/to/test/link ; some comment\n"+
"2024-01-06 txn ; \"test\" \"test2\" #tag ^link\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 6, len(actualData.transactions))
assert.Equal(t, "2024-01-01", actualData.transactions[0].date)
assert.Equal(t, beancountDirectiveCompletedTransaction, actualData.transactions[0].directive)
assert.Equal(t, "", actualData.transactions[0].payee)
assert.Equal(t, "", actualData.transactions[0].narration)
assert.Equal(t, "2024-01-02", actualData.transactions[1].date)
assert.Equal(t, beancountDirectiveCompletedTransaction, actualData.transactions[1].directive)
assert.Equal(t, "", actualData.transactions[1].payee)
assert.Equal(t, "test\ttest2\ntest3", actualData.transactions[1].narration)
assert.Equal(t, "2024-01-03", actualData.transactions[2].date)
assert.Equal(t, beancountDirectiveInCompleteTransaction, actualData.transactions[2].directive)
assert.Equal(t, "test", actualData.transactions[2].payee)
assert.Equal(t, "test2", actualData.transactions[2].narration)
assert.Equal(t, "2024-01-04", actualData.transactions[3].date)
assert.Equal(t, beancountDirectivePaddingTransaction, actualData.transactions[3].directive)
assert.Equal(t, "", actualData.transactions[3].payee)
assert.Equal(t, "test", actualData.transactions[3].narration)
assert.Equal(t, 2, len(actualData.transactions[3].tags))
assert.Equal(t, actualData.transactions[3].tags[0], "tag")
assert.Equal(t, actualData.transactions[3].tags[1], "tag2")
assert.Equal(t, "2024-01-05", actualData.transactions[4].date)
assert.Equal(t, beancountDirectiveTransaction, actualData.transactions[4].directive)
assert.Equal(t, "", actualData.transactions[4].payee)
assert.Equal(t, "test", actualData.transactions[4].narration)
assert.Equal(t, 1, len(actualData.transactions[4].links))
assert.Equal(t, actualData.transactions[4].links[0], "scheme://path/to/test/link")
assert.Equal(t, "2024-01-06", actualData.transactions[5].date)
assert.Equal(t, beancountDirectiveTransaction, actualData.transactions[5].directive)
assert.Equal(t, "", actualData.transactions[5].payee)
assert.Equal(t, "", actualData.transactions[5].narration)
}
func TestBeancountDataReaderReadTransactionPostingLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory -123.45 CNY ; some comment\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-01-02 *\n"+
" Liabilities:TestAccount2 -0.23 USD ; some comment\n"+
" Expenses:TestCategory2 0.12 USD @@ 0.84 CNY\n"+
" Expenses:TestCategory3 0.11 USD @ 7.12 CNY\n"+
" ! Expenses:TestCategory4 0.00 USD {0.00 CNY}\n"+
" Expenses:TestCategory5 \n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 2, len(actualData.transactions))
assert.Equal(t, "2024-01-01", actualData.transactions[0].date)
assert.Equal(t, 2, len(actualData.transactions[0].postings))
assert.Equal(t, "Income:TestCategory", actualData.transactions[0].postings[0].account)
assert.Equal(t, "-123.45", actualData.transactions[0].postings[0].amount)
assert.Equal(t, "CNY", actualData.transactions[0].postings[0].commodity)
assert.Equal(t, "Assets:TestAccount", actualData.transactions[0].postings[1].account)
assert.Equal(t, "123.45", actualData.transactions[0].postings[1].amount)
assert.Equal(t, "CNY", actualData.transactions[0].postings[1].commodity)
assert.Equal(t, "2024-01-02", actualData.transactions[1].date)
assert.Equal(t, 4, len(actualData.transactions[1].postings))
assert.Equal(t, "Liabilities:TestAccount2", actualData.transactions[1].postings[0].account)
assert.Equal(t, "-0.23", actualData.transactions[1].postings[0].amount)
assert.Equal(t, "USD", actualData.transactions[1].postings[0].commodity)
assert.Equal(t, "Expenses:TestCategory2", actualData.transactions[1].postings[1].account)
assert.Equal(t, "0.12", actualData.transactions[1].postings[1].amount)
assert.Equal(t, "USD", actualData.transactions[1].postings[1].commodity)
assert.Equal(t, "0.84", actualData.transactions[1].postings[1].totalCost)
assert.Equal(t, "CNY", actualData.transactions[1].postings[1].totalCostCommodity)
assert.Equal(t, "Expenses:TestCategory3", actualData.transactions[1].postings[2].account)
assert.Equal(t, "0.11", actualData.transactions[1].postings[2].amount)
assert.Equal(t, "USD", actualData.transactions[1].postings[2].commodity)
assert.Equal(t, "7.12", actualData.transactions[1].postings[2].price)
assert.Equal(t, "CNY", actualData.transactions[1].postings[2].priceCommodity)
assert.Equal(t, "0.00", actualData.transactions[1].postings[3].amount)
assert.Equal(t, "USD", actualData.transactions[1].postings[3].commodity)
}
func TestBeancountDataReaderReadTransactionPostingLine_AmountExpression(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory (1.2-3.4) * 5.6 / 7.8 CNY\n"+
" Assets:TestAccount 1.2 * 3.4/-5.6 - 7.8 CNY\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 1, len(actualData.transactions))
assert.Equal(t, "2024-01-01", actualData.transactions[0].date)
assert.Equal(t, 2, len(actualData.transactions[0].postings))
assert.Equal(t, "Income:TestCategory", actualData.transactions[0].postings[0].account)
assert.Equal(t, "(1.2-3.4) * 5.6 / 7.8", actualData.transactions[0].postings[0].originalAmount)
assert.Equal(t, "-1.58", actualData.transactions[0].postings[0].amount)
assert.Equal(t, "CNY", actualData.transactions[0].postings[0].commodity)
assert.Equal(t, "Assets:TestAccount", actualData.transactions[0].postings[1].account)
assert.Equal(t, "1.2 * 3.4/-5.6 - 7.8", actualData.transactions[0].postings[1].originalAmount)
assert.Equal(t, "-8.53", actualData.transactions[0].postings[1].amount)
assert.Equal(t, "CNY", actualData.transactions[0].postings[1].commodity)
}
func TestBeancountDataReaderReadTransactionPostingLine_InvalidAmountExpression(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory (1.2-3.4)*5.6/0 CNY\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
reader, err = createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount abc CNY\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestBeancountDataReaderReadTransactionPostingLine_InvalidAccountType(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory -123.45 CNY\n"+
" Test:TestAccount 123.45 CNY\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionPostingLine_InvalidCommodity(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Income:TestCategory -123.45 cny\n"+
" Assets:TestAccount 123.45 cny\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionPostingLine_MissingAmount(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 1, len(actualData.transactions))
assert.Equal(t, 0, len(actualData.transactions[0].postings))
reader, err = createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount \n"))
assert.Nil(t, err)
actualData, err = reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 1, len(actualData.transactions))
assert.Equal(t, 0, len(actualData.transactions[0].postings))
}
func TestBeancountDataReaderReadTransactionPostingLine_MissingCommodity(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount 123.45\n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
reader, err = createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" Assets:TestAccount 123.45 \n"))
assert.Nil(t, err)
_, err = reader.read(context)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
func TestBeancountDataReaderReadTransactionMetadataLine(t *testing.T) {
context := core.NewNullContext()
reader, err := createNewBeancountDataReader(context, []byte(""+
"2024-01-01 *\n"+
" key: value\n"+
" key2: \"value 2\"\n"+
" key3: \n"+
" key4: \"\"\n"+
" key5 : \"\"\n"+
" key2: \"new value\"\n"+
" Income:TestCategory -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-01-02 *\n"+
" Liabilities:TestAccount2 -0.23 USD\n"+
" key6: value6\n"+
" key7: \"value 7\"\n"+
" key8: \n"+
" key9: \"\"\n"+
" key0 : \"\"\n"+
" key6: \"new value\"\n"+
" Expenses:TestCategory2 0.12 USD\n"))
assert.Nil(t, err)
actualData, err := reader.read(context)
assert.Nil(t, err)
assert.Equal(t, 2, len(actualData.transactions))
assert.Equal(t, "2024-01-01", actualData.transactions[0].date)
assert.Equal(t, 2, len(actualData.transactions[0].postings))
assert.Equal(t, 2, len(actualData.transactions[0].metadata))
assert.Equal(t, "value", actualData.transactions[0].metadata["key"])
assert.Equal(t, "value 2", actualData.transactions[0].metadata["key2"])
assert.Equal(t, "2024-01-02", actualData.transactions[1].date)
assert.Equal(t, 2, len(actualData.transactions[1].postings))
assert.Equal(t, 2, len(actualData.transactions[1].postings[0].metadata))
assert.Equal(t, "value6", actualData.transactions[1].postings[0].metadata["key6"])
assert.Equal(t, "value 7", actualData.transactions[1].postings[0].metadata["key7"])
assert.Equal(t, 0, len(actualData.transactions[1].postings[1].metadata))
}
@@ -0,0 +1,41 @@
package beancount
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBeancountAccount_IsOpeningBalanceEquityAccount_True(t *testing.T) {
account := beancountAccount{
accountType: beancountEquityAccountType,
name: "Equity:Opening-Balances",
}
assert.True(t, account.isOpeningBalanceEquityAccount())
account = beancountAccount{
accountType: beancountEquityAccountType,
name: "E:Opening-Balances",
}
assert.True(t, account.isOpeningBalanceEquityAccount())
}
func TestBeancountAccount_IsOpeningBalanceEquityAccount_False(t *testing.T) {
account := beancountAccount{
accountType: beancountAssetsAccountType,
name: "Equity:Opening-Balances",
}
assert.False(t, account.isOpeningBalanceEquityAccount())
account = beancountAccount{
accountType: beancountEquityAccountType,
name: "Opening-Balances",
}
assert.False(t, account.isOpeningBalanceEquityAccount())
account = beancountAccount{
accountType: beancountEquityAccountType,
name: "Equity:Other",
}
assert.False(t, account.isOpeningBalanceEquityAccount())
}
@@ -0,0 +1,49 @@
package beancount
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var beancountTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
}
// beancountTransactionDataImporter defines the structure of Beancount importer for transaction data
type beancountTransactionDataImporter struct {
}
// Initialize a beancount transaction data importer singleton instance
var (
BeancountTransactionDataImporter = &beancountTransactionDataImporter{}
)
// ParseImportedData returns the imported data by parsing the Beancount transaction data
func (c *beancountTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
beancountDataReader, err := createNewBeancountDataReader(ctx, data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
beancountData, err := beancountDataReader.read(ctx)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable, err := createNewBeancountTransactionDataTable(beancountData)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(beancountTransactionTypeNameMapping, "", BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,358 @@
package beancount
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-09-02 *\n"+
" Income:TestCategory -0.12 CNY\n"+
" Assets:TestAccount 0.12 CNY\n"+
"2024-09-03 *\n"+
" Assets:TestAccount -1.00 CNY\n"+
" Expenses:TestCategory2 1.00 CNY\n"+
"2024-09-04 *\n"+
" Assets:TestAccount -0.05 CNY\n"+
" Assets:TestAccount2 0.05 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
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, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Assets:TestAccount", 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, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Income:TestCategory", 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, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Expenses:TestCategory2", 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, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Expenses:TestCategory2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Income:TestCategory", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestBeancountTransactionDataFileParseImportedData_MinimumValidData2(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Assets:TestAccount 123.45 CNY\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
"2024-09-02 *\n"+
" Assets:TestAccount 0.12 CNY\n"+
" Income:TestCategory -0.12 CNY\n"+
"2024-09-03 *\n"+
" Expenses:TestCategory2 1.00 CNY\n"+
" Assets:TestAccount -1.00 CNY\n"+
"2024-09-04 *\n"+
" Assets:TestAccount2 0.05 CNY\n"+
" Assets:TestAccount -0.05 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
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, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, "Assets:TestAccount", 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, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Income:TestCategory", 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, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Expenses:TestCategory2", 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, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Expenses:TestCategory2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Income:TestCategory", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "", allNewSubTransferCategories[0].Name)
}
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024/09/01 *\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestBeancountTransactionDataFileParseImportedData_ParseValidCurrency(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Assets:TestAccount -0.12 USD\n"+
" Assets:TestAccount2 0.84 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
assert.Equal(t, int64(84), allNewTransactions[0].RelatedAccountAmount)
assert.Equal(t, "Assets:TestAccount", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, "Assets:TestAccount2", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, "CNY", allNewTransactions[0].OriginalDestinationAccountCurrency)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Assets:TestAccount", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Assets:TestAccount2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
}
func TestBeancountTransactionDataFileParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -abc CNY\n"+
" Assets:TestAccount abc CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 *\n"+
" Equity:Opening-Balances -1/0 CNY\n"+
" Assets:TestAccount 1/0 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestBeancountTransactionDataFileParseImportedData_ParseDescription(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"foo bar\t#test\n\"\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"+
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Income:TestCategory -0.12 CNY\n"+
" Assets:TestAccount 0.12 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test\n", allNewTransactions[0].Comment)
assert.Equal(t, "Hello\nWorld", allNewTransactions[1].Comment)
}
func TestBeancountTransactionDataFileParseImportedData_InvalidTransaction(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Assets:TestAccount 0.11 CNY\n"+
" Assets:TestAccount2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Expenses:TestCategory -0.11 CNY\n"+
" Expenses:TestCategory2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Income:TestCategory -0.11 CNY\n"+
" Income:TestCategory2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Equity:TestCategory -0.11 CNY\n"+
" Equity:TestCategory2 0.11 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrThereAreNotSupportedTransactionType.Message)
}
func TestBeancountTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"2024-09-02 * \"Payee Name\" \"Hello\nWorld\"\n"+
" Assets:TestAccount -0.23 CNY\n"+
" Assets:TestAccount2 0.11 CNY\n"+
" Assets:TestAccount3 0.12 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
}
func TestBeancountTransactionDataFileParseImportedData_MissingTransactionRequiredData(t *testing.T) {
converter := BeancountTransactionDataImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
// Missing Transaction Time
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"* \"narration\"\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" Assets:TestAccount 123.45 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
// Missing Account Name
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances -123.45 CNY\n"+
" 123.45 CNY\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
// Missing Amount
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances\n"+
" Assets:TestAccount\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
// Missing Commodity
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"2024-09-01 * \"narration\"\n"+
" Equity:Opening-Balances -123.45\n"+
" Assets:TestAccount 123.45\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidBeancountFile.Message)
}
@@ -0,0 +1,248 @@
package beancount
import (
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var beancountTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
}
var BEANCOUNT_TRANSACTION_TAG_SEPARATOR = "#"
// beancountTransactionDataTable defines the structure of Beancount transaction data table
type beancountTransactionDataTable struct {
allData []*beancountTransactionEntry
accountMap map[string]*beancountAccount
}
// beancountTransactionDataRow defines the structure of Beancount transaction data row
type beancountTransactionDataRow struct {
dataTable *beancountTransactionDataTable
data *beancountTransactionEntry
finalItems map[datatable.TransactionDataTableColumn]string
}
// beancountTransactionDataRowIterator defines the structure of Beancount transaction data row iterator
type beancountTransactionDataRowIterator struct {
dataTable *beancountTransactionDataTable
currentIndex int
}
// HasColumn returns whether the transaction data table has specified column
func (t *beancountTransactionDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
_, exists := beancountTransactionSupportedColumns[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *beancountTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *beancountTransactionDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &beancountTransactionDataRowIterator{
dataTable: t,
currentIndex: -1,
}
}
// IsValid returns whether this row is valid data for importing
func (r *beancountTransactionDataRow) IsValid() bool {
return true
}
// GetData returns the data in the specified column type
func (r *beancountTransactionDataRow) GetData(column datatable.TransactionDataTableColumn) string {
_, exists := beancountTransactionSupportedColumns[column]
if exists {
return r.finalItems[column]
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *beancountTransactionDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allData)
}
// Next returns the next imported data row
func (t *beancountTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
if t.currentIndex+1 >= len(t.dataTable.allData) {
return nil, nil
}
t.currentIndex++
data := t.dataTable.allData[t.currentIndex]
rowItems, err := t.parseTransaction(ctx, user, data)
if err != nil {
return nil, err
}
return &beancountTransactionDataRow{
dataTable: t.dataTable,
data: data,
finalItems: rowItems,
}, nil
}
func (t *beancountTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, beancountEntry *beancountTransactionEntry) (map[datatable.TransactionDataTableColumn]string, error) {
data := make(map[datatable.TransactionDataTableColumn]string, len(beancountTransactionSupportedColumns))
if beancountEntry.date == "" {
return nil, errs.ErrMissingTransactionTime
}
// Beancount supports the international ISO 8601 standard format for dates, with dashes or the same ordering with slashes
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = strings.ReplaceAll(beancountEntry.date, "/", "-") + " 00:00:00"
if len(beancountEntry.postings) == 2 {
splitData1 := beancountEntry.postings[0]
splitData2 := beancountEntry.postings[1]
account1 := t.dataTable.accountMap[splitData1.account]
account2 := t.dataTable.accountMap[splitData2.account]
if account1 == nil || account2 == nil {
return nil, errs.ErrMissingAccountData
}
amount1, err := utils.ParseAmount(splitData1.amount)
if err != nil {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData1.amount, err.Error())
return nil, errs.ErrAmountInvalid
}
amount2, err := utils.ParseAmount(splitData2.amount)
if err != nil {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse amount \"%s\", because %s", splitData2.amount, err.Error())
return nil, errs.ErrAmountInvalid
}
if ((account1.accountType == beancountEquityAccountType || account1.accountType == beancountIncomeAccountType) && (account2.accountType == beancountAssetsAccountType || account2.accountType == beancountLiabilitiesAccountType)) ||
((account2.accountType == beancountEquityAccountType || account2.accountType == beancountIncomeAccountType) && (account1.accountType == beancountAssetsAccountType || account1.accountType == beancountLiabilitiesAccountType)) { // income
fromAccount := account1
toAccount := account2
toCurrency := splitData2.commodity
toAmount := amount2
if (account2.accountType == beancountEquityAccountType || account2.accountType == beancountIncomeAccountType) && (account1.accountType == beancountAssetsAccountType || account1.accountType == beancountLiabilitiesAccountType) {
fromAccount = account2
toAccount = account1
toCurrency = splitData1.commodity
toAmount = amount1
}
if fromAccount.isOpeningBalanceEquityAccount() {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE))
} else {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_INCOME))
}
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = fromAccount.name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = toAccount.name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = toCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(toAmount)
} else if account1.accountType == beancountExpensesAccountType && (account2.accountType == beancountAssetsAccountType || account2.accountType == beancountLiabilitiesAccountType) ||
(account2.accountType == beancountExpensesAccountType && (account1.accountType == beancountAssetsAccountType || account1.accountType == beancountLiabilitiesAccountType)) { // expense
fromAccount := account1
fromCurrency := splitData1.commodity
fromAmount := amount1
toAccount := account2
if account1.accountType == beancountExpensesAccountType && (account2.accountType == beancountAssetsAccountType || account2.accountType == beancountLiabilitiesAccountType) {
fromAccount = account2
fromCurrency = splitData2.commodity
fromAmount = amount2
toAccount = account1
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE))
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = toAccount.name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-fromAmount)
} else if (account1.accountType == beancountAssetsAccountType || account1.accountType == beancountLiabilitiesAccountType) &&
(account2.accountType == beancountAssetsAccountType || account2.accountType == beancountLiabilitiesAccountType) {
var fromAccount, toAccount *beancountAccount
var fromAmount, toAmount int64
var fromCurrency, toCurrency string
if amount1 < 0 {
fromAccount = account1
fromCurrency = splitData1.commodity
fromAmount = -amount1
toAccount = account2
toCurrency = splitData2.commodity
toAmount = amount2
} else if amount2 < 0 {
fromAccount = account2
fromCurrency = splitData2.commodity
fromAmount = -amount2
toAccount = account1
toCurrency = splitData1.commodity
toAmount = amount1
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transfer transaction, because unexcepted account amounts \"%d\" and \"%d\"", amount1, amount2)
return nil, errs.ErrInvalidBeancountFile
}
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER))
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = fromAccount.name
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = fromCurrency
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(fromAmount)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = toAccount.name
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = toCurrency
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(toAmount)
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because unexcepted account types \"%d\" and \"%d\"", account1.accountType, account2.accountType)
return nil, errs.ErrThereAreNotSupportedTransactionType
}
} else if len(beancountEntry.postings) <= 1 {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse transaction, because postings count is %d", len(beancountEntry.postings))
return nil, errs.ErrInvalidBeancountFile
} else {
log.Errorf(ctx, "[beancount_transaction_data_table.parseTransaction] cannot parse split transaction, because postings count is %d", len(beancountEntry.postings))
return nil, errs.ErrNotSupportedSplitTransactions
}
data[datatable.TRANSACTION_DATA_TABLE_TAGS] = strings.Join(beancountEntry.tags, BEANCOUNT_TRANSACTION_TAG_SEPARATOR)
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = beancountEntry.narration
return data, nil
}
func createNewBeancountTransactionDataTable(beancountData *beancountData) (*beancountTransactionDataTable, error) {
if beancountData == nil {
return nil, errs.ErrNotFoundTransactionDataInFile
}
return &beancountTransactionDataTable{
allData: beancountData.transactions,
accountMap: beancountData.accounts,
}, nil
}
@@ -0,0 +1,166 @@
package converter
import (
"fmt"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// DataTableTransactionDataExporter defines the structure of plain text data table exporter for transaction data
type DataTableTransactionDataExporter struct {
transactionTypeMapping map[models.TransactionType]string
geoLocationSeparator string
transactionTagSeparator string
}
// BuildExportedContent writes the exported transaction data to the data table builder
func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context, dataTableBuilder datatable.TransactionDataTableBuilder, 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[datatable.TransactionDataTableColumn]string, 15)
transactionTimeZone := time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(transactionTimeZone)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataTableBuilder.ReplaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type))
dataRowMap[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
}
dataRowMap[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TAGS] = c.getExportedTags(dataTableBuilder, transaction.TransactionId, allTagIndexes, tagMap)
dataRowMap[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataTableBuilder.ReplaceDelimiters(transaction.Comment)
dataTableBuilder.AppendTransaction(dataRowMap)
}
return nil
}
func (c *DataTableTransactionDataExporter) getDisplayTransactionTypeName(transactionDbType models.TransactionDbType) string {
transactionType, err := transactionDbType.ToTransactionType()
if err != nil {
return ""
}
transactionTypeName, exists := c.transactionTypeMapping[transactionType]
if !exists {
return ""
}
return transactionTypeName
}
func (c *DataTableTransactionDataExporter) getExportedTransactionCategoryName(dataTableBuilder datatable.TransactionDataTableBuilder, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if !exists {
return ""
}
if category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
return dataTableBuilder.ReplaceDelimiters(category.Name)
}
parentCategory, exists := categoryMap[category.ParentCategoryId]
if !exists {
return ""
}
return dataTableBuilder.ReplaceDelimiters(parentCategory.Name)
}
func (c *DataTableTransactionDataExporter) getExportedTransactionSubCategoryName(dataTableBuilder datatable.TransactionDataTableBuilder, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if exists {
return dataTableBuilder.ReplaceDelimiters(category.Name)
} else {
return ""
}
}
func (c *DataTableTransactionDataExporter) getExportedAccountName(dataTableBuilder datatable.TransactionDataTableBuilder, accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return dataTableBuilder.ReplaceDelimiters(account.Name)
} else {
return ""
}
}
func (c *DataTableTransactionDataExporter) getAccountCurrency(dataTableBuilder datatable.TransactionDataTableBuilder, accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return dataTableBuilder.ReplaceDelimiters(account.Currency)
} else {
return ""
}
}
func (c *DataTableTransactionDataExporter) 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 *DataTableTransactionDataExporter) getExportedTags(dataTableBuilder datatable.TransactionDataTableBuilder, 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 dataTableBuilder.ReplaceDelimiters(ret.String())
}
// CreateNewExporter returns a new data table transaction data exporter according to the specified arguments
func CreateNewExporter(transactionTypeMapping map[models.TransactionType]string, geoLocationSeparator string, transactionTagSeparator string) *DataTableTransactionDataExporter {
return &DataTableTransactionDataExporter{
transactionTypeMapping: transactionTypeMapping,
geoLocationSeparator: geoLocationSeparator,
transactionTagSeparator: transactionTagSeparator,
}
}
@@ -0,0 +1,503 @@
package converter
import (
"sort"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
// DataTableTransactionDataImporter defines the structure of plain text data table importer for transaction data
type DataTableTransactionDataImporter struct {
transactionTypeMapping map[string]models.TransactionType
geoLocationSeparator string
transactionTagSeparator string
}
// ParseImportedData returns the imported transaction data
func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, user *models.User, dataTable datatable.TransactionDataTable, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
if dataTable.TransactionRowCount() < 1 {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because data table row count is less 1", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
nameDbTypeMap, err := c.buildTransactionTypeNameDbTypeMap()
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if !dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_AMOUNT) ||
!dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME) {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse import data for user \"uid:%d\", because missing essential columns in header row", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if accountMap == nil {
accountMap = make(map[string]*models.Account)
}
if expenseCategoryMap == nil {
expenseCategoryMap = make(map[string]map[string]*models.TransactionCategory)
}
if incomeCategoryMap == nil {
incomeCategoryMap = make(map[string]map[string]*models.TransactionCategory)
}
if transferCategoryMap == nil {
transferCategoryMap = make(map[string]map[string]*models.TransactionCategory)
}
if tagMap == nil {
tagMap = make(map[string]*models.TransactionTag)
}
allNewTransactions := make(models.ImportedTransactionSlice, 0, dataTable.TransactionRowCount())
allNewAccounts := make([]*models.Account, 0)
allNewSubExpenseCategories := make([]*models.TransactionCategory, 0)
allNewSubIncomeCategories := make([]*models.TransactionCategory, 0)
allNewSubTransferCategories := make([]*models.TransactionCategory, 0)
allNewTags := make([]*models.TransactionTag, 0)
dataRowIterator := dataTable.TransactionRowIterator()
dataRowIndex := 0
for dataRowIterator.HasNext() {
dataRowIndex++
dataRow, err := dataRowIterator.Next(ctx, user)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse data row \"index:%d\" for user \"uid:%d\", because %s", dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, err
}
if !dataRow.IsValid() {
continue
}
timezoneOffset := defaultTimezoneOffset
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE) {
transactionTimezone, err := utils.ParseFromTimezoneOffset(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse time zone \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrTransactionTimeZoneInvalid
}
timezoneOffset = utils.GetTimezoneOffsetMinutes(transactionTimezone)
}
transactionTime, err := utils.ParseFromLongDateTime(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME), timezoneOffset)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse time \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrTransactionTimeInvalid
}
transactionDbType, err := c.getTransactionDbType(nameDbTypeMap, dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse transaction type \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.Or(err, errs.ErrTransactionTypeInvalid)
}
categoryId := int64(0)
categoryName := ""
subCategoryName := ""
if transactionDbType != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
transactionCategoryType, err := c.getTransactionCategoryType(transactionDbType)
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse transaction category type in data row \"index:%d\" for user \"uid:%d\", because %s", dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.Or(err, errs.ErrTransactionTypeInvalid)
}
categoryName = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_CATEGORY)
subCategoryName = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY)
if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE {
subCategory, exists := c.getTransactionCategory(expenseCategoryMap, categoryName, subCategoryName)
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubExpenseCategories = append(allNewSubExpenseCategories, subCategory)
if _, exists = expenseCategoryMap[subCategoryName]; !exists {
expenseCategoryMap[subCategoryName] = make(map[string]*models.TransactionCategory)
}
expenseCategoryMap[subCategoryName][categoryName] = subCategory
}
categoryId = subCategory.CategoryId
} else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME {
subCategory, exists := c.getTransactionCategory(incomeCategoryMap, categoryName, subCategoryName)
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubIncomeCategories = append(allNewSubIncomeCategories, subCategory)
if _, exists = incomeCategoryMap[subCategoryName]; !exists {
incomeCategoryMap[subCategoryName] = make(map[string]*models.TransactionCategory)
}
incomeCategoryMap[subCategoryName][categoryName] = subCategory
}
categoryId = subCategory.CategoryId
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
subCategory, exists := c.getTransactionCategory(transferCategoryMap, categoryName, subCategoryName)
if !exists {
subCategory = c.createNewTransactionCategoryModel(user.Uid, subCategoryName, transactionCategoryType)
allNewSubTransferCategories = append(allNewSubTransferCategories, subCategory)
if _, exists = transferCategoryMap[subCategoryName]; !exists {
transferCategoryMap[subCategoryName] = make(map[string]*models.TransactionCategory)
}
transferCategoryMap[subCategoryName][categoryName] = subCategory
}
categoryId = subCategory.CategoryId
}
}
accountName := dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
accountCurrency := user.DefaultCurrency
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) {
accountCurrency = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY)
if _, ok := validators.AllCurrencyNames[accountCurrency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] account currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", accountCurrency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account, exists := accountMap[accountName]
if !exists {
account = c.createNewAccountModel(user.Uid, accountName, accountCurrency)
allNewAccounts = append(allNewAccounts, account)
accountMap[accountName] = account
}
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY) {
if account.Name != "" && account.Currency != accountCurrency {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account for user \"uid:%d\"", accountCurrency, dataRowIndex, account.Currency, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
accountCurrency = account.Currency
}
amount, err := utils.ParseAmount(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_AMOUNT))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse acmount \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_AMOUNT), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrAmountInvalid
}
relatedAccountId := int64(0)
relatedAccountAmount := int64(0)
account2Name := ""
account2Currency := ""
if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2Name = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME)
account2Currency = user.DefaultCurrency
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) {
account2Currency = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY)
if _, ok := validators.AllCurrencyNames[account2Currency]; !ok {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] account2 currency \"%s\" is not supported in data row \"index:%d\" for user \"uid:%d\"", account2Currency, dataRowIndex, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
}
account2, exists := accountMap[account2Name]
if !exists {
account2 = c.createNewAccountModel(user.Uid, account2Name, account2Currency)
allNewAccounts = append(allNewAccounts, account2)
accountMap[account2Name] = account2
}
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY) {
if account2.Name != "" && account2.Currency != account2Currency {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] currency \"%s\" in data row \"index:%d\" not equals currency \"%s\" of the account2 for user \"uid:%d\"", account2Currency, dataRowIndex, account2.Currency, user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrAccountCurrencyInvalid
}
} else if exists {
account2Currency = account2.Currency
}
relatedAccountId = account2.AccountId
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT) {
relatedAccountAmount, err = utils.ParseAmount(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT))
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse acmount2 \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrAmountInvalid
}
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
relatedAccountAmount = amount
}
}
geoLongitude := float64(0)
geoLatitude := float64(0)
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) && c.geoLocationSeparator != "" {
geoLocationItems := strings.Split(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), c.geoLocationSeparator)
if len(geoLocationItems) == 2 {
geoLongitude, err = utils.StringToFloat64(geoLocationItems[0])
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse geographic location \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrGeographicLocationInvalid
}
geoLatitude, err = utils.StringToFloat64(geoLocationItems[1])
if err != nil {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] cannot parse geographic location \"%s\" in data row \"index:%d\" for user \"uid:%d\", because %s", dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), dataRowIndex, user.Uid, err.Error())
return nil, nil, nil, nil, nil, nil, errs.ErrGeographicLocationInvalid
}
}
}
var tagIds []string
var tagNames []string
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_TAGS) {
var tagNameItems []string
if c.transactionTagSeparator != "" {
tagNameItems = strings.Split(dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
} else {
tagNameItems = append(tagNameItems, dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_TAGS))
}
for i := 0; i < len(tagNameItems); i++ {
tagName := tagNameItems[i]
if tagName == "" {
continue
}
tag, exists := tagMap[tagName]
if !exists {
tag = c.createNewTransactionTagModel(user.Uid, tagName)
allNewTags = append(allNewTags, tag)
tagMap[tagName] = tag
}
if tag != nil {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
tagNames = append(tagNames, tagName)
}
}
description := ""
if dataTable.HasColumn(datatable.TRANSACTION_DATA_TABLE_DESCRIPTION) {
description = dataRow.GetData(datatable.TRANSACTION_DATA_TABLE_DESCRIPTION)
}
transaction := &models.ImportTransaction{
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",
},
TagIds: tagIds,
OriginalCategoryName: subCategoryName,
OriginalSourceAccountName: accountName,
OriginalSourceAccountCurrency: accountCurrency,
OriginalDestinationAccountName: account2Name,
OriginalDestinationAccountCurrency: account2Currency,
OriginalTagNames: tagNames,
}
allNewTransactions = append(allNewTransactions, transaction)
}
if len(allNewTransactions) < 1 {
log.Errorf(ctx, "[data_table_transaction_data_exporter.ParseImportedData] no transaction data parsed for \"uid:%d\"", user.Uid)
return nil, nil, nil, nil, nil, nil, errs.ErrNotFoundTransactionDataInFile
}
sort.Sort(allNewTransactions)
return allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, nil
}
func (c *DataTableTransactionDataImporter) buildTransactionTypeNameDbTypeMap() (map[string]models.TransactionDbType, error) {
if c.transactionTypeMapping == nil {
return nil, errs.ErrTransactionTypeInvalid
}
nameDbTypeMap := make(map[string]models.TransactionDbType, len(c.transactionTypeMapping))
for name, transactionType := range c.transactionTypeMapping {
if transactionType == models.TRANSACTION_TYPE_MODIFY_BALANCE {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_MODIFY_BALANCE
} else if transactionType == models.TRANSACTION_TYPE_INCOME {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_INCOME
} else if transactionType == models.TRANSACTION_TYPE_EXPENSE {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_EXPENSE
} else if transactionType == models.TRANSACTION_TYPE_TRANSFER {
nameDbTypeMap[name] = models.TRANSACTION_DB_TYPE_TRANSFER_OUT
} else {
return nil, errs.ErrTransactionTypeInvalid
}
}
return nameDbTypeMap, nil
}
func (c *DataTableTransactionDataImporter) getTransactionDbType(nameDbTypeMap map[string]models.TransactionDbType, transactionTypeName string) (models.TransactionDbType, error) {
transactionType, exists := nameDbTypeMap[transactionTypeName]
if !exists {
return 0, errs.ErrTransactionTypeInvalid
}
return transactionType, nil
}
func (c *DataTableTransactionDataImporter) 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 *DataTableTransactionDataImporter) getTransactionCategory(categories map[string]map[string]*models.TransactionCategory, categoryName string, subCategoryName string) (*models.TransactionCategory, bool) {
if len(categories) < 1 {
return nil, false
}
subCategories, exists := categories[subCategoryName]
if !exists || len(subCategories) < 1 {
return nil, false
}
if categoryName == "" {
for _, subCategory := range subCategories {
if subCategory != nil {
return subCategory, true
}
}
}
subCategory, exists := subCategories[categoryName]
if !exists {
for _, subCategory := range subCategories {
if subCategory != nil {
return subCategory, true
}
}
}
return subCategory, exists
}
func (c *DataTableTransactionDataImporter) createNewAccountModel(uid int64, accountName string, currency string) *models.Account {
return &models.Account{
Uid: uid,
Name: accountName,
Currency: currency,
}
}
func (c *DataTableTransactionDataImporter) createNewTransactionCategoryModel(uid int64, categoryName string, transactionCategoryType models.TransactionCategoryType) *models.TransactionCategory {
return &models.TransactionCategory{
Uid: uid,
Name: categoryName,
Type: transactionCategoryType,
}
}
func (c *DataTableTransactionDataImporter) createNewTransactionTagModel(uid int64, tagName string) *models.TransactionTag {
return &models.TransactionTag{
Uid: uid,
Name: tagName,
}
}
// CreateNewImporterWithTypeNameMapping returns a new data table transaction data importer according to the specified arguments
func CreateNewImporterWithTypeNameMapping(transactionTypeMapping map[models.TransactionType]string, geoLocationSeparator string, transactionTagSeparator string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: buildTransactionNameTypeMap(transactionTypeMapping),
geoLocationSeparator: geoLocationSeparator,
transactionTagSeparator: transactionTagSeparator,
}
}
// CreateNewSimpleImporter returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporter(transactionTypeMapping map[string]models.TransactionType) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: transactionTypeMapping,
}
}
// CreateNewSimpleImporterWithTypeNameMapping returns a new data table transaction data importer according to the specified arguments
func CreateNewSimpleImporterWithTypeNameMapping(transactionTypeMapping map[models.TransactionType]string) *DataTableTransactionDataImporter {
return &DataTableTransactionDataImporter{
transactionTypeMapping: buildTransactionNameTypeMap(transactionTypeMapping),
}
}
func buildTransactionNameTypeMap(transactionTypeMapping map[models.TransactionType]string) map[string]models.TransactionType {
if transactionTypeMapping == nil {
return nil
}
typeNameMap := make(map[string]models.TransactionType, len(transactionTypeMapping))
for transactionType, name := range transactionTypeMapping {
typeNameMap[name] = transactionType
}
return typeNameMap
}
@@ -0,0 +1,24 @@
package converter
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// TransactionDataExporter defines the structure of transaction data exporter
type TransactionDataExporter interface {
// ToExportedContent returns the exported data
ToExportedContent(ctx core.Context, 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(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error)
}
// TransactionDataConverter defines the structure of transaction data converter
type TransactionDataConverter interface {
TransactionDataExporter
TransactionDataImporter
}
@@ -0,0 +1,138 @@
package csv
import (
"encoding/csv"
"fmt"
"io"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
)
// CsvFileImportedDataTable defines the structure of csv data table
type CsvFileImportedDataTable struct {
allLines [][]string
}
// CsvFileImportedDataRow defines the structure of csv data table row
type CsvFileImportedDataRow struct {
dataTable *CsvFileImportedDataTable
allItems []string
}
// CsvFileImportedDataRowIterator defines the structure of csv data table row iterator
type CsvFileImportedDataRowIterator struct {
dataTable *CsvFileImportedDataTable
currentIndex int
}
// DataRowCount returns the total count of data row
func (t *CsvFileImportedDataTable) DataRowCount() int {
if len(t.allLines) < 1 {
return 0
}
return len(t.allLines) - 1
}
// HeaderColumnNames returns the header column name list
func (t *CsvFileImportedDataTable) HeaderColumnNames() []string {
if len(t.allLines) < 1 {
return nil
}
return t.allLines[0]
}
// DataRowIterator returns the iterator of data row
func (t *CsvFileImportedDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &CsvFileImportedDataRowIterator{
dataTable: t,
currentIndex: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *CsvFileImportedDataRow) ColumnCount() int {
return len(r.allItems)
}
// GetData returns the data in the specified column index
func (r *CsvFileImportedDataRow) GetData(columnIndex int) string {
if columnIndex >= len(r.allItems) {
return ""
}
return r.allItems[columnIndex]
}
// HasNext returns whether the iterator does not reach the end
func (t *CsvFileImportedDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allLines)
}
// CurrentRowId returns current index
func (t *CsvFileImportedDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("line#%d", t.currentIndex)
}
// Next returns the next imported data row
func (t *CsvFileImportedDataRowIterator) Next() datatable.ImportedDataRow {
if t.currentIndex+1 >= len(t.dataTable.allLines) {
return nil
}
t.currentIndex++
rowItems := t.dataTable.allLines[t.currentIndex]
return &CsvFileImportedDataRow{
dataTable: t.dataTable,
allItems: rowItems,
}
}
// CreateNewCsvImportedDataTable returns comma separated values data table by io readers
func CreateNewCsvImportedDataTable(ctx core.Context, reader io.Reader) (*CsvFileImportedDataTable, error) {
return createNewCsvFileDataTable(ctx, reader, ',')
}
// CreateNewCustomCsvImportedDataTable returns character separated values data table by io readers
func CreateNewCustomCsvImportedDataTable(allLines [][]string) *CsvFileImportedDataTable {
return &CsvFileImportedDataTable{
allLines: allLines,
}
}
func createNewCsvFileDataTable(ctx core.Context, reader io.Reader, separator rune) (*CsvFileImportedDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.Comma = separator
csvReader.FieldsPerRecord = -1
allLines := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[csv_file_imported_data_table.createNewCsvFileDataTable] cannot parse csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if len(items) == 1 && items[0] == "" {
continue
}
allLines = append(allLines, items)
}
return &CsvFileImportedDataTable{
allLines: allLines,
}, nil
}
@@ -0,0 +1,184 @@
package csv
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
)
func TestCsvFileImportedDataTableDataRowCount(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
assert.Equal(t, 2, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
})
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableDataRowCount_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{})
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestCsvFileImportedDataTableHeaderColumnNames(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestCsvFileImportedDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{})
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestCsvFileImportedDataRowIterator(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 3
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestCsvFileImportedDataRowColumnCount(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
}
func TestCsvFileImportedDataRowGetData(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
}
func TestCsvFileImportedDataRowGetData_GetNotExistedColumnData(t *testing.T) {
datatable := CreateNewCustomCsvImportedDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
})
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "", row1.GetData(3))
}
func TestCreateNewCsvImportedDataTable(t *testing.T) {
context := core.NewNullContext()
reader := bytes.NewReader([]byte("A1,B1,C1\n" +
"A2,B2,C2\n" +
"A3,B3,C3\n"))
datatable, err := CreateNewCsvImportedDataTable(context, reader)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
assert.True(t, iterator.HasNext())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
assert.False(t, iterator.HasNext())
}
func TestCreateNewCsvImportedDataTable_SkipBlankLine(t *testing.T) {
context := core.NewNullContext()
reader := bytes.NewReader([]byte("\n" +
"A1,B1,C1\n" +
"A2,B2,C2\n" +
"\n" +
"A3,B3,C3\n"))
datatable, err := CreateNewCsvImportedDataTable(context, reader)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
assert.True(t, iterator.HasNext())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
assert.False(t, iterator.HasNext())
}
-13
View File
@@ -1,13 +0,0 @@
package converters
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// DataConverter defines the structure of data exporter
type DataConverter interface {
// ToExportedContent returns the exported data
ToExportedContent(uid int64, timezone *time.Location, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexs map[int64][]int64) ([]byte, error)
}
@@ -0,0 +1,40 @@
package datatable
// CommonDataTable defines the structure of common data table
type CommonDataTable interface {
// HeaderColumnCount returns the total count of column in header row
HeaderColumnCount() int
// HasColumn returns whether the common data table has specified column name
HasColumn(columnName string) bool
// DataRowCount returns the total count of common data row
DataRowCount() int
// DataRowIterator returns the iterator of common data row
DataRowIterator() CommonDataRowIterator
}
// CommonDataRow defines the structure of common data row
type CommonDataRow interface {
// ColumnCount returns the total count of column in this data row
ColumnCount() int
// HasData returns whether the common data row has specified column data
HasData(columnName string) bool
// GetData returns the data in the specified column name
GetData(columnName string) string
}
// CommonDataRowIterator defines the structure of common data row iterator
type CommonDataRowIterator interface {
// HasNext returns whether the iterator does not reach the end
HasNext() bool
// CurrentRowId returns current row id
CurrentRowId() string
// Next returns the next common data row
Next() CommonDataRow
}
@@ -0,0 +1,114 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// CommonTransactionDataTable defines the structure of common transaction data table
type CommonTransactionDataTable struct {
innerDataTable CommonDataTable
supportedDataColumns map[TransactionDataTableColumn]bool
rowParser CommonTransactionDataRowParser
}
// CommonTransactionDataRow defines the structure of common transaction data row
type CommonTransactionDataRow struct {
transactionDataTable *CommonTransactionDataTable
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// CommonTransactionDataRowIterator defines the structure of common transaction data row iterator
type CommonTransactionDataRowIterator struct {
transactionDataTable *CommonTransactionDataTable
innerIterator CommonDataRowIterator
}
// CommonTransactionDataRowParser defines the structure of common transaction data row parser
type CommonTransactionDataRowParser interface {
// Parse returns the converted transaction data row
Parse(ctx core.Context, user *models.User, dataTable *CommonTransactionDataTable, dataRow CommonDataRow, rowId string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error)
}
// HasColumn returns whether the data table has specified column
func (t *CommonTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
_, exists := t.supportedDataColumns[column]
return exists
}
// HasOriginalColumn returns whether the original data table has specified column name
func (t *CommonTransactionDataTable) HasOriginalColumn(columnName string) bool {
return columnName != "" && t.innerDataTable.HasColumn(columnName)
}
// TransactionRowCount returns the total count of transaction data row
func (t *CommonTransactionDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *CommonTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &CommonTransactionDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *CommonTransactionDataRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *CommonTransactionDataRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
_, exists := r.transactionDataTable.supportedDataColumns[column]
if !exists {
return ""
}
return r.rowData[column]
}
// HasNext returns whether the iterator does not reach the end
func (t *CommonTransactionDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *CommonTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
commonRow := t.innerIterator.Next()
if commonRow == nil {
return nil, nil
}
rowId := t.innerIterator.CurrentRowId()
rowData, rowDataValid, err := t.transactionDataTable.rowParser.Parse(ctx, user, t.transactionDataTable, commonRow, rowId)
if err != nil {
log.Errorf(ctx, "[common_transaction_data_table.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
return &CommonTransactionDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewCommonTransactionDataTable returns transaction data table from Common data table
func CreateNewCommonTransactionDataTable(dataTable CommonDataTable, supportedDataColumns map[TransactionDataTableColumn]bool, rowParser CommonTransactionDataRowParser) *CommonTransactionDataTable {
return &CommonTransactionDataTable{
innerDataTable: dataTable,
supportedDataColumns: supportedDataColumns,
rowParser: rowParser,
}
}
@@ -0,0 +1,107 @@
package datatable
// ImportedCommonDataTable defines the structure of imported common data table
type ImportedCommonDataTable struct {
innerDataTable ImportedDataTable
dataColumnIndexes map[string]int
}
// ImportedCommonDataRow defines the structure of imported common data row
type ImportedCommonDataRow struct {
rowData map[string]string
}
// ImportedCommonDataRowIterator defines the structure of imported common data row iterator
type ImportedCommonDataRowIterator struct {
commonDataTable *ImportedCommonDataTable
innerIterator ImportedDataRowIterator
}
// HeaderColumnCount returns the total count of column in header row
func (t *ImportedCommonDataTable) HeaderColumnCount() int {
return len(t.innerDataTable.HeaderColumnNames())
}
// HasColumn returns whether the data table has specified column name
func (t *ImportedCommonDataTable) HasColumn(columnName string) bool {
index, exists := t.dataColumnIndexes[columnName]
return exists && index >= 0
}
// DataRowCount returns the total count of common data row
func (t *ImportedCommonDataTable) DataRowCount() int {
return t.innerDataTable.DataRowCount()
}
// DataRowIterator returns the iterator of common data row
func (t *ImportedCommonDataTable) DataRowIterator() CommonDataRowIterator {
return &ImportedCommonDataRowIterator{
commonDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// HasData returns whether the common data row has specified column data
func (r *ImportedCommonDataRow) HasData(columnName string) bool {
_, exists := r.rowData[columnName]
return exists
}
// ColumnCount returns the total count of column in this data row
func (r *ImportedCommonDataRow) ColumnCount() int {
return len(r.rowData)
}
// GetData returns the data in the specified column name
func (r *ImportedCommonDataRow) GetData(columnName string) string {
return r.rowData[columnName]
}
// HasNext returns whether the iterator does not reach the end
func (t *ImportedCommonDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// CurrentRowId returns current row id
func (t *ImportedCommonDataRowIterator) CurrentRowId() string {
return t.innerIterator.CurrentRowId()
}
// Next returns the next common data row
func (t *ImportedCommonDataRowIterator) Next() CommonDataRow {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil
}
rowData := make(map[string]string, len(t.commonDataTable.dataColumnIndexes))
for column, columnIndex := range t.commonDataTable.dataColumnIndexes {
if columnIndex < 0 || columnIndex >= importedRow.ColumnCount() {
continue
}
value := importedRow.GetData(columnIndex)
rowData[column] = value
}
return &ImportedCommonDataRow{
rowData: rowData,
}
}
// CreateNewImportedCommonDataTable returns common data table from imported data table
func CreateNewImportedCommonDataTable(dataTable ImportedDataTable) *ImportedCommonDataTable {
headerLineItems := dataTable.HeaderColumnNames()
dataColumnIndexes := make(map[string]int, len(headerLineItems))
for i := 0; i < len(headerLineItems); i++ {
dataColumnIndexes[headerLineItems[i]] = i
}
return &ImportedCommonDataTable{
innerDataTable: dataTable,
dataColumnIndexes: dataColumnIndexes,
}
}
@@ -0,0 +1,34 @@
package datatable
// ImportedDataTable defines the structure of imported data table
type ImportedDataTable interface {
// DataRowCount returns the total count of data row
DataRowCount() int
// HeaderColumnNames returns the header column name list
HeaderColumnNames() []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
// CurrentRowId returns current row id
CurrentRowId() string
// Next returns the next imported data row
Next() ImportedDataRow
}
@@ -0,0 +1,188 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// ImportedTransactionDataTable defines the structure of imported transaction data table
type ImportedTransactionDataTable struct {
innerDataTable ImportedDataTable
dataColumnMapping map[TransactionDataTableColumn]string
dataColumnIndexes map[TransactionDataTableColumn]int
rowParser TransactionDataRowParser
addedColumns map[TransactionDataTableColumn]bool
}
// ImportedTransactionDataRow defines the structure of imported transaction data row
type ImportedTransactionDataRow struct {
transactionDataTable *ImportedTransactionDataTable
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// ImportedTransactionDataRowIterator defines the structure of imported transaction data row iterator
type ImportedTransactionDataRowIterator struct {
transactionDataTable *ImportedTransactionDataTable
innerIterator ImportedDataRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *ImportedTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
index, exists := t.dataColumnIndexes[column]
if exists && index >= 0 {
return exists
}
if t.addedColumns != nil {
_, exists = t.addedColumns[column]
if exists {
return exists
}
}
return false
}
// TransactionRowCount returns the total count of transaction data row
func (t *ImportedTransactionDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *ImportedTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &ImportedTransactionDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *ImportedTransactionDataRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *ImportedTransactionDataRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
_, exists := r.transactionDataTable.dataColumnIndexes[column]
if exists {
return r.rowData[column]
}
if r.transactionDataTable.addedColumns != nil {
_, exists = r.transactionDataTable.addedColumns[column]
if exists {
return r.rowData[column]
}
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *ImportedTransactionDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *ImportedTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil, nil
}
if importedRow.ColumnCount() == 1 && importedRow.GetData(0) == "" {
return &ImportedTransactionDataRow{
transactionDataTable: t.transactionDataTable,
rowData: nil,
rowDataValid: false,
}, nil
}
if importedRow.ColumnCount() < len(t.transactionDataTable.dataColumnIndexes) {
log.Errorf(ctx, "[imported_transaction_data_table.Next] cannot parse data row, because may missing some columns (column count %d in data row is less than header column count %d)", importedRow.ColumnCount(), len(t.transactionDataTable.dataColumnIndexes))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
rowData := make(map[TransactionDataTableColumn]string, len(t.transactionDataTable.dataColumnIndexes))
rowDataValid := true
for column, columnIndex := range t.transactionDataTable.dataColumnIndexes {
if columnIndex < 0 || columnIndex >= importedRow.ColumnCount() {
continue
}
value := importedRow.GetData(columnIndex)
rowData[column] = value
}
if t.transactionDataTable.rowParser != nil {
rowData, rowDataValid, err = t.transactionDataTable.rowParser.Parse(rowData)
if err != nil {
log.Errorf(ctx, "[imported_transaction_data_table.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
}
return &ImportedTransactionDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewImportedTransactionDataTable returns transaction data table from imported data table
func CreateNewImportedTransactionDataTable(dataTable ImportedDataTable, dataColumnMapping map[TransactionDataTableColumn]string) *ImportedTransactionDataTable {
return CreateNewImportedTransactionDataTableWithRowParser(dataTable, dataColumnMapping, nil)
}
// CreateNewImportedTransactionDataTableWithRowParser returns transaction data table from imported data table
func CreateNewImportedTransactionDataTableWithRowParser(dataTable ImportedDataTable, dataColumnMapping map[TransactionDataTableColumn]string, rowParser TransactionDataRowParser) *ImportedTransactionDataTable {
headerLineItems := dataTable.HeaderColumnNames()
headerItemMap := make(map[string]int, len(headerLineItems))
for i := 0; i < len(headerLineItems); i++ {
headerItemMap[headerLineItems[i]] = i
}
dataColumnIndexes := make(map[TransactionDataTableColumn]int, len(headerLineItems))
for column, columnName := range dataColumnMapping {
columnIndex, exists := headerItemMap[columnName]
if exists {
dataColumnIndexes[column] = columnIndex
}
}
var addedColumns map[TransactionDataTableColumn]bool
if rowParser != nil {
addedColumnsByParser := rowParser.GetAddedColumns()
addedColumns = make(map[TransactionDataTableColumn]bool, len(addedColumnsByParser))
for i := 0; i < len(addedColumnsByParser); i++ {
addedColumns[addedColumnsByParser[i]] = true
}
}
return &ImportedTransactionDataTable{
innerDataTable: dataTable,
dataColumnMapping: dataColumnMapping,
dataColumnIndexes: dataColumnIndexes,
rowParser: rowParser,
addedColumns: addedColumns,
}
}
@@ -0,0 +1,75 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// TransactionDataTable defines the structure of transaction data table
type TransactionDataTable interface {
// HasColumn returns whether the transaction data table has specified column
HasColumn(column TransactionDataTableColumn) bool
// TransactionRowCount returns the total count of transaction data row
TransactionRowCount() int
// TransactionRowIterator returns the iterator of transaction data row
TransactionRowIterator() TransactionDataRowIterator
}
// TransactionDataRow defines the structure of transaction data row
type TransactionDataRow interface {
// IsValid returns whether this row is valid data for importing
IsValid() bool
// GetData returns the data in the specified column type
GetData(column TransactionDataTableColumn) string
}
// TransactionDataRowIterator defines the structure of transaction data row iterator
type TransactionDataRowIterator interface {
// HasNext returns whether the iterator does not reach the end
HasNext() bool
// Next returns the next transaction data row
Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error)
}
// TransactionDataRowParser defines the structure of transaction data row parser
type TransactionDataRowParser interface {
// GetAddedColumns returns the added columns after converting the data row
GetAddedColumns() []TransactionDataTableColumn
// Parse returns the converted transaction data row
Parse(data map[TransactionDataTableColumn]string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error)
}
// TransactionDataTableBuilder defines the structure of data table builder
type TransactionDataTableBuilder interface {
// AppendTransaction appends the specified transaction to data builder
AppendTransaction(data map[TransactionDataTableColumn]string)
// ReplaceDelimiters returns the text after removing the delimiters
ReplaceDelimiters(text string) string
}
// TransactionDataTableColumn represents the data column type of data table
type TransactionDataTableColumn byte
// Transaction data table columns
const (
TRANSACTION_DATA_TABLE_TRANSACTION_TIME TransactionDataTableColumn = 1
TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE TransactionDataTableColumn = 2
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE TransactionDataTableColumn = 3
TRANSACTION_DATA_TABLE_CATEGORY TransactionDataTableColumn = 4
TRANSACTION_DATA_TABLE_SUB_CATEGORY TransactionDataTableColumn = 5
TRANSACTION_DATA_TABLE_ACCOUNT_NAME TransactionDataTableColumn = 6
TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY TransactionDataTableColumn = 7
TRANSACTION_DATA_TABLE_AMOUNT TransactionDataTableColumn = 8
TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME TransactionDataTableColumn = 9
TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY TransactionDataTableColumn = 10
TRANSACTION_DATA_TABLE_RELATED_AMOUNT TransactionDataTableColumn = 11
TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION TransactionDataTableColumn = 12
TRANSACTION_DATA_TABLE_TAGS TransactionDataTableColumn = 13
TRANSACTION_DATA_TABLE_DESCRIPTION TransactionDataTableColumn = 14
)
@@ -0,0 +1,210 @@
package datatable
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// WritableTransactionDataTable defines the structure of writable transaction data table
type WritableTransactionDataTable struct {
allData []map[TransactionDataTableColumn]string
supportedColumns map[TransactionDataTableColumn]bool
rowParser TransactionDataRowParser
addedColumns map[TransactionDataTableColumn]bool
}
// WritableTransactionDataRow defines the structure of transaction data row of writable data table
type WritableTransactionDataRow struct {
dataTable *WritableTransactionDataTable
rowData map[TransactionDataTableColumn]string
rowDataValid bool
}
// WritableTransactionDataRowIterator defines the structure of transaction data row iterator of writable data table
type WritableTransactionDataRowIterator struct {
dataTable *WritableTransactionDataTable
nextIndex int
}
// Add appends a new record to data table
func (t *WritableTransactionDataTable) Add(data map[TransactionDataTableColumn]string) {
finalData := make(map[TransactionDataTableColumn]string, len(data))
for column, value := range data {
_, exists := t.supportedColumns[column]
if exists {
finalData[column] = value
}
}
t.allData = append(t.allData, finalData)
}
// Get returns the record in the specified index
func (t *WritableTransactionDataTable) Get(index int) (*WritableTransactionDataRow, error) {
if index >= len(t.allData) {
return nil, nil
}
rowData := t.allData[index]
rowDataValid := true
if t.rowParser != nil {
var err error
rowData, rowDataValid, err = t.rowParser.Parse(rowData)
if err != nil {
return nil, err
}
}
return &WritableTransactionDataRow{
dataTable: t,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// HasColumn returns whether the data table has specified column
func (t *WritableTransactionDataTable) HasColumn(column TransactionDataTableColumn) bool {
_, exists := t.supportedColumns[column]
if exists {
return exists
}
if t.addedColumns != nil {
_, exists = t.addedColumns[column]
if exists {
return exists
}
}
return false
}
// TransactionRowCount returns the total count of transaction data row
func (t *WritableTransactionDataTable) TransactionRowCount() int {
return len(t.allData)
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *WritableTransactionDataTable) TransactionRowIterator() TransactionDataRowIterator {
return &WritableTransactionDataRowIterator{
dataTable: t,
nextIndex: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *WritableTransactionDataRow) ColumnCount() int {
if !r.rowDataValid {
return 0
}
columnCount := 0
for column := range r.rowData {
if r.dataTable.supportedColumns[column] || r.dataTable.addedColumns[column] {
columnCount++
}
}
return columnCount
}
// IsValid returns whether this row is valid data for importing
func (r *WritableTransactionDataRow) IsValid() bool {
return r.rowDataValid
}
// GetData returns the data in the specified column type
func (r *WritableTransactionDataRow) GetData(column TransactionDataTableColumn) string {
if !r.rowDataValid {
return ""
}
_, exists := r.dataTable.supportedColumns[column]
if exists {
return r.rowData[column]
}
if r.dataTable.addedColumns != nil {
_, exists = r.dataTable.addedColumns[column]
if exists {
return r.rowData[column]
}
}
return ""
}
// HasNext returns whether the iterator does not reach the end
func (t *WritableTransactionDataRowIterator) HasNext() bool {
return t.nextIndex < len(t.dataTable.allData)
}
// Next returns the next transaction data row
func (t *WritableTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow TransactionDataRow, err error) {
if t.nextIndex >= len(t.dataTable.allData) {
return nil, nil
}
rowData := t.dataTable.allData[t.nextIndex]
rowDataValid := true
if t.dataTable.rowParser != nil {
rowData, rowDataValid, err = t.dataTable.rowParser.Parse(rowData)
if err != nil {
log.Errorf(ctx, "[writable_transaction_data_table.Next] cannot parse data row, because %s", err.Error())
return nil, err
}
}
t.nextIndex++
return &WritableTransactionDataRow{
dataTable: t.dataTable,
rowData: rowData,
rowDataValid: rowDataValid,
}, nil
}
// CreateNewWritableTransactionDataTable returns a new writable transaction data table according to the specified columns
func CreateNewWritableTransactionDataTable(columns []TransactionDataTableColumn) *WritableTransactionDataTable {
return CreateNewWritableTransactionDataTableWithRowParser(columns, nil)
}
// CreateNewWritableTransactionDataTableWithRowParser returns a new writable transaction data table according to the specified columns
func CreateNewWritableTransactionDataTableWithRowParser(columns []TransactionDataTableColumn, rowParser TransactionDataRowParser) *WritableTransactionDataTable {
supportedColumns := make(map[TransactionDataTableColumn]bool, len(columns))
for i := 0; i < len(columns); i++ {
column := columns[i]
supportedColumns[column] = true
}
var addedColumns map[TransactionDataTableColumn]bool
if rowParser != nil {
addedColumnsByParser := rowParser.GetAddedColumns()
addedColumns = make(map[TransactionDataTableColumn]bool, len(addedColumnsByParser))
for i := 0; i < len(addedColumnsByParser); i++ {
addedColumns[addedColumnsByParser[i]] = true
}
}
return &WritableTransactionDataTable{
allData: make([]map[TransactionDataTableColumn]string, 0),
supportedColumns: supportedColumns,
rowParser: rowParser,
addedColumns: addedColumns,
}
}
@@ -0,0 +1,380 @@
package datatable
import (
"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"
)
// testDataRowParser defines the structure of test transaction data row parser
type testDataRowParser struct {
}
// GetAddedColumns returns the added columns after converting the data row
func (p *testDataRowParser) GetAddedColumns() []TransactionDataTableColumn {
return []TransactionDataTableColumn{
TRANSACTION_DATA_TABLE_DESCRIPTION,
}
}
// Parse returns the converted transaction data row
func (p *testDataRowParser) Parse(data map[TransactionDataTableColumn]string) (rowData map[TransactionDataTableColumn]string, rowDataValid bool, err error) {
rowData = make(map[TransactionDataTableColumn]string, len(data))
for column, value := range data {
rowData[column] = value
}
if _, exists := rowData[TRANSACTION_DATA_TABLE_SUB_CATEGORY]; exists {
rowData[TRANSACTION_DATA_TABLE_SUB_CATEGORY] = "foo"
} else {
return nil, false, nil
}
rowData[TRANSACTION_DATA_TABLE_TAGS] = "test"
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "bar"
return rowData, true, nil
}
func TestWritableDataTableCreate(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_SUB_CATEGORY))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_NAME))
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY))
}
func TestWritableDataTableAdd(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
expectedTransactionTime := "2024-09-01 01:23:45"
expectedTransactionType := "Expense"
expectedSubCategory := "Test Category"
expectedAccountName := "Test Account"
expectedAmount := "123.45"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTime,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionType,
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategory,
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountName,
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmount,
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.True(t, dataRow.IsValid())
actualTransactionTime := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME)
assert.Equal(t, expectedTransactionTime, actualTransactionTime)
actualTransactionType := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
assert.Equal(t, expectedTransactionType, actualTransactionType)
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, expectedSubCategory, actualSubCategory)
actualAccountName := dataRow.GetData(TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
assert.Equal(t, expectedAccountName, actualAccountName)
actualAmount := dataRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT)
assert.Equal(t, expectedAmount, actualAmount)
}
func TestWritableDataTableAdd_NotExistsColumn(t *testing.T) {
columns := make([]TransactionDataTableColumn, 1)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
writableDataTable := CreateNewWritableTransactionDataTable(columns)
expectedTransactionUnixTime := time.Now().Unix()
expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local)
expectedTransactionType := "Expense"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionType,
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.Equal(t, 1, dataRow.ColumnCount())
}
func TestWritableDataTableGet_NotExistsRow(t *testing.T) {
columns := make([]TransactionDataTableColumn, 1)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.Nil(t, dataRow)
}
func TestWritableDataRowGetData_NotExistsColumn(t *testing.T) {
columns := make([]TransactionDataTableColumn, 1)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
writableDataTable := CreateNewWritableTransactionDataTable(columns)
expectedTransactionUnixTime := time.Now().Unix()
expectedTextualTransactionTime := utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTime, time.Local)
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTextualTransactionTime,
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.Equal(t, 1, dataRow.ColumnCount())
assert.Equal(t, "", dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
}
func TestWritableDataTableDataRowIterator(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTable(columns)
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
expectedTransactionUnixTimes := make([]int64, 3)
expectedTransactionTimes := make([]string, 3)
expectedTransactionTypes := make([]string, 3)
expectedSubCategories := make([]string, 3)
expectedAccountNames := make([]string, 3)
expectedAmounts := make([]string, 3)
expectedTransactionUnixTimes[0] = time.Now().Add(-5 * time.Hour).Unix()
expectedTransactionTimes[0] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[0], time.Local)
expectedTransactionTypes[0] = "Balance Modification"
expectedSubCategories[0] = ""
expectedAccountNames[0] = "Test Account"
expectedAmounts[0] = "123.45"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTimes[0],
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[0],
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategories[0],
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[0],
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmounts[0],
})
expectedTransactionUnixTimes[1] = time.Now().Add(-45 * time.Minute).Unix()
expectedTransactionTimes[1] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[1], time.Local)
expectedTransactionTypes[1] = "Expense"
expectedSubCategories[1] = "Test Category2"
expectedAccountNames[1] = "Test Account"
expectedAmounts[1] = "-23.4"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTimes[1],
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[1],
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategories[1],
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[1],
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmounts[1],
})
expectedTransactionUnixTimes[2] = time.Now().Unix()
expectedTransactionTimes[2] = utils.FormatUnixTimeToLongDateTime(expectedTransactionUnixTimes[2], time.Local)
expectedTransactionTypes[2] = "Income"
expectedSubCategories[2] = "Test Category3"
expectedAccountNames[2] = "Test Account2"
expectedAmounts[2] = "123"
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: expectedTransactionTimes[2],
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: expectedTransactionTypes[2],
TRANSACTION_DATA_TABLE_SUB_CATEGORY: expectedSubCategories[2],
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: expectedAccountNames[2],
TRANSACTION_DATA_TABLE_AMOUNT: expectedAmounts[2],
})
assert.Equal(t, 3, writableDataTable.TransactionRowCount())
index := 0
iterator := writableDataTable.TransactionRowIterator()
for iterator.HasNext() {
dataRow, err := iterator.Next(core.NewNullContext(), &models.User{})
assert.Nil(t, err)
actualTransactionTime := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME)
assert.Equal(t, expectedTransactionTimes[index], actualTransactionTime)
actualTransactionType := dataRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
assert.Equal(t, expectedTransactionTypes[index], actualTransactionType)
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, expectedSubCategories[index], actualSubCategory)
actualAccountName := dataRow.GetData(TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
assert.Equal(t, expectedAccountNames[index], actualAccountName)
actualAmount := dataRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT)
assert.Equal(t, expectedAmounts[index], actualAmount)
index++
}
assert.Equal(t, 3, index)
}
func TestWritableDataTableWithRowParser(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTableWithRowParser(columns, &testDataRowParser{})
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS))
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 01:23:45",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Expense",
TRANSACTION_DATA_TABLE_SUB_CATEGORY: "Test Category",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account",
TRANSACTION_DATA_TABLE_AMOUNT: "123.45",
})
assert.Equal(t, 1, writableDataTable.TransactionRowCount())
// first row
dataRow, err := writableDataTable.Get(0)
assert.Nil(t, err)
assert.True(t, dataRow.IsValid())
assert.Equal(t, 6, dataRow.ColumnCount())
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "foo", actualSubCategory)
actualTags := dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription := dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "bar", actualDescription)
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 12:34:56",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Income",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account2",
TRANSACTION_DATA_TABLE_AMOUNT: "0.12",
})
assert.Equal(t, 2, writableDataTable.TransactionRowCount())
// second row
dataRow, err = writableDataTable.Get(1)
assert.Nil(t, err)
assert.False(t, dataRow.IsValid())
assert.Equal(t, 0, dataRow.ColumnCount())
actualSubCategory = dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "", actualSubCategory)
actualTags = dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription = dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "", actualDescription)
}
func TestWritableDataTableDataRowIteratorWithRowParser(t *testing.T) {
columns := make([]TransactionDataTableColumn, 5)
columns[0] = TRANSACTION_DATA_TABLE_TRANSACTION_TIME
columns[1] = TRANSACTION_DATA_TABLE_TRANSACTION_TYPE
columns[2] = TRANSACTION_DATA_TABLE_SUB_CATEGORY
columns[3] = TRANSACTION_DATA_TABLE_ACCOUNT_NAME
columns[4] = TRANSACTION_DATA_TABLE_AMOUNT
writableDataTable := CreateNewWritableTransactionDataTableWithRowParser(columns, &testDataRowParser{})
assert.True(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, writableDataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS))
assert.Equal(t, 0, writableDataTable.TransactionRowCount())
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 01:23:45",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Expense",
TRANSACTION_DATA_TABLE_SUB_CATEGORY: "Test Category",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account",
TRANSACTION_DATA_TABLE_AMOUNT: "123.45",
})
writableDataTable.Add(map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "2024-09-01 12:34:56",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Income",
TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Test Account2",
TRANSACTION_DATA_TABLE_AMOUNT: "0.12",
})
iterator := writableDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
// first row
dataRow, err := iterator.Next(core.NewNullContext(), &models.User{})
assert.Nil(t, err)
assert.True(t, dataRow.IsValid())
actualSubCategory := dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "foo", actualSubCategory)
actualTags := dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription := dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "bar", actualDescription)
assert.True(t, iterator.HasNext())
// second row
dataRow, err = iterator.Next(core.NewNullContext(), &models.User{})
assert.Nil(t, err)
assert.False(t, dataRow.IsValid())
actualSubCategory = dataRow.GetData(TRANSACTION_DATA_TABLE_SUB_CATEGORY)
assert.Equal(t, "", actualSubCategory)
actualTags = dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS)
assert.Equal(t, "", actualTags)
actualDescription = dataRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION)
assert.Equal(t, "", actualDescription)
assert.False(t, iterator.HasNext())
}
@@ -0,0 +1,15 @@
package _default
// defaultTransactionDataCSVFileConverter defines the structure of ezbookkeeping default csv file converter
type defaultTransactionDataCSVFileConverter struct {
defaultTransactionDataPlainTextConverter
}
// Initialize an ezbookkeeping default transaction data csv file converter singleton instance
var (
DefaultTransactionDataCSVFileConverter = &defaultTransactionDataCSVFileConverter{
defaultTransactionDataPlainTextConverter{
columnSeparator: ",",
},
}
)
@@ -0,0 +1,106 @@
package _default
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// defaultTransactionDataPlainTextConverter defines the structure of ezbookkeeping default plain text converter for transaction data
type defaultTransactionDataPlainTextConverter struct {
columnSeparator string
}
const ezbookkeepingLineSeparator = "\n"
const ezbookkeepingGeoLocationSeparator = " "
const ezbookkeepingTagSeparator = ";"
var ezbookkeepingDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "Time",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: "Timezone",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "Type",
datatable.TRANSACTION_DATA_TABLE_CATEGORY: "Category",
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "Sub Category",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "Account",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: "Account Currency",
datatable.TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "Account2",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "Account2 Currency",
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: "Account2 Amount",
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION: "Geographic Location",
datatable.TRANSACTION_DATA_TABLE_TAGS: "Tags",
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "Description",
}
var ezbookkeepingTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: "Balance Modification",
models.TRANSACTION_TYPE_INCOME: "Income",
models.TRANSACTION_TYPE_EXPENSE: "Expense",
models.TRANSACTION_TYPE_TRANSFER: "Transfer",
}
var ezbookkeepingDataColumns = []datatable.TransactionDataTableColumn{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE,
datatable.TRANSACTION_DATA_TABLE_CATEGORY,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY,
datatable.TRANSACTION_DATA_TABLE_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION,
datatable.TRANSACTION_DATA_TABLE_TAGS,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION,
}
// ToExportedContent returns the exported transaction plain text data
func (c *defaultTransactionDataPlainTextConverter) ToExportedContent(ctx core.Context, 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 := createNewDefaultTransactionPlainTextDataTableBuilder(
len(transactions),
ezbookkeepingDataColumns,
ezbookkeepingDataColumnNameMapping,
c.columnSeparator,
ezbookkeepingLineSeparator,
)
dataTableExporter := converter.CreateNewExporter(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingTagSeparator,
)
err := dataTableExporter.BuildExportedContent(ctx, dataTableBuilder, uid, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
if err != nil {
return nil, err
}
return []byte(dataTableBuilder.String()), nil
}
// ParseImportedData returns the imported data by parsing the transaction plain text data
func (c *defaultTransactionDataPlainTextConverter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
dataTable, err := createNewDefaultPlainTextDataTable(
string(data),
c.columnSeparator,
ezbookkeepingLineSeparator,
)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
transactionDataTable := datatable.CreateNewImportedTransactionDataTable(dataTable, ezbookkeepingDataColumnNameMapping)
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingTagSeparator,
)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,520 @@
package _default
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestDefaultTransactionDataCSVFileConverterToExportedContent(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
transactions := make([]*models.Transaction, 3)
transactions[0] = &models.Transaction{
TransactionId: 1,
TransactionTime: 1725165296000,
Type: models.TRANSACTION_DB_TYPE_INCOME,
TimezoneUtcOffset: 480,
CategoryId: 2,
AccountId: 1,
Amount: 12345,
GeoLongitude: 123.45,
GeoLatitude: 45.67,
Comment: "Hello,World",
}
transactions[1] = &models.Transaction{
TransactionId: 2,
TransactionTime: 1725194096000,
Type: models.TRANSACTION_DB_TYPE_EXPENSE,
TimezoneUtcOffset: 0,
CategoryId: 4,
AccountId: 1,
Amount: -10,
GeoLongitude: 0,
GeoLatitude: 0,
Comment: "Foo#Bar",
}
transactions[2] = &models.Transaction{
TransactionId: 3,
TransactionTime: 1725212096000,
Type: models.TRANSACTION_DB_TYPE_TRANSFER_OUT,
TimezoneUtcOffset: -300,
CategoryId: 6,
AccountId: 1,
Amount: 12345,
RelatedAccountId: 2,
RelatedAccountAmount: 1735,
Comment: "T\te\rs\nt\r\ntest",
}
accountMap := make(map[int64]*models.Account, 2)
accountMap[1] = &models.Account{
AccountId: 1,
Name: "Test Account",
Currency: "CNY",
}
accountMap[2] = &models.Account{
AccountId: 2,
Name: "Test Account2",
Currency: "USD",
}
categoryMap := make(map[int64]*models.TransactionCategory, 6)
categoryMap[1] = &models.TransactionCategory{
CategoryId: 1,
Type: models.CATEGORY_TYPE_INCOME,
Name: "Test Category",
}
categoryMap[2] = &models.TransactionCategory{
CategoryId: 2,
Type: models.CATEGORY_TYPE_INCOME,
ParentCategoryId: 1,
Name: "Test Sub Category",
}
categoryMap[3] = &models.TransactionCategory{
CategoryId: 3,
Type: models.CATEGORY_TYPE_EXPENSE,
Name: "Test Category2",
}
categoryMap[4] = &models.TransactionCategory{
CategoryId: 4,
Type: models.CATEGORY_TYPE_EXPENSE,
ParentCategoryId: 3,
Name: "Test Sub Category2",
}
categoryMap[5] = &models.TransactionCategory{
CategoryId: 5,
Type: models.CATEGORY_TYPE_TRANSFER,
Name: "Test Category3",
}
categoryMap[6] = &models.TransactionCategory{
CategoryId: 6,
Type: models.CATEGORY_TYPE_TRANSFER,
ParentCategoryId: 5,
Name: "Test Sub Category3",
}
tagMap := make(map[int64]*models.TransactionTag, 2)
tagMap[1] = &models.TransactionTag{
TagId: 1,
Name: "Test,Tag",
}
tagMap[2] = &models.TransactionTag{
TagId: 2,
Name: "Test;Tag2",
}
allTagIndexes := make(map[int64][]int64, 2)
allTagIndexes[1] = []int64{1, 2}
allTagIndexes[2] = []int64{3, 1, 4}
allTagIndexes[3] = []int64{2, 3}
expectedContent := "Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Geographic Location,Tags,Description\n" +
"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(context, 123, transactions, accountMap, categoryMap, tagMap, allTagIndexes)
assert.Nil(t, err)
assert.Equal(t, expectedContent, string(actualContent))
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_MinimumValidData(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, 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"+
"2024-09-01 23:59:59,Transfer,Test Category3,Test Account,0.05,Test Account2,0.05"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
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, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
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, int64(1725153825), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
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, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(100), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account", 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, int64(1725235199), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(5), allNewTransactions[3].Amount)
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), 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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseValidTimezone(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,-10:00,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,+00:00,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Timezone,Type,Sub Category,Account,Amount,Account2,Account2 Amount\n"+
"2024-09-01 12:34:56,+12:45,Expense,Test Category,Test Account,123.45,,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidTimezone(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeZoneInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseValidAccountCurrency(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, 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 Category2,Test Account,USD,1.23,Test Account2,EUR,1.10"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "EUR", allNewAccounts[1].Currency)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseNotSupportedCurrency(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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,XXX,123.45,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseNoAmount2(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2\n"+
"2024-09-01 12:34:56,Expense,Test Category,Test Account,123.45,"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, int64(0), allNewTransactions[0].RelatedAccountAmount)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("Time,Type,Sub Category,Account,Amount,Account2\n"+
"2024-09-01 12:34:56,Transfer,Test Category,Test Account,123.45,Test Account2"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, int64(12345), allNewTransactions[0].Amount)
assert.Equal(t, int64(12345), allNewTransactions[0].RelatedAccountAmount)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseValidGeographicLocation(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, 123.45, allNewTransactions[0].GeoLongitude)
assert.Equal(t, 45.56, allNewTransactions[0].GeoLatitude)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseInvalidGeographicLocation(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, 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, 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(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrGeographicLocationInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrGeographicLocationInvalid.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseTag(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, 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, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 4, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTags[0].Uid)
assert.Equal(t, "foo", allNewTags[0].Name)
assert.Equal(t, int64(1234567890), allNewTags[1].Uid)
assert.Equal(t, "bar.", allNewTags[1].Name)
assert.Equal(t, int64(1234567890), allNewTags[2].Uid)
assert.Equal(t, "#test", allNewTags[2].Name)
assert.Equal(t, int64(1234567890), allNewTags[3].Uid)
assert.Equal(t, "hello\tworld", allNewTags[3].Name)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_ParseDescription(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, 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, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "foo bar\t#test", allNewTransactions[0].Comment)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_MissingFileHeader(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestDefaultTransactionDataCSVFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := DefaultTransactionDataCSVFileConverter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, 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 Sub Category,Test Account,CNY,123.45,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Sub Category Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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 Sub Category,CNY,123.45,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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 Sub Category,Test Account,CNY,,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account2 Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, 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 Sub Category,Test Account,CNY,123.45,,,,,"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
@@ -0,0 +1,15 @@
package _default
// defaultTransactionDataTSVFileConverter defines the structure of ezbookkeeping default tsv file converter
type defaultTransactionDataTSVFileConverter struct {
defaultTransactionDataPlainTextConverter
}
// Initialize an ezbookkeeping default transaction data tsv file converter singleton instance
var (
DefaultTransactionDataTSVFileConverter = &defaultTransactionDataTSVFileConverter{
defaultTransactionDataPlainTextConverter{
columnSeparator: "\t",
},
}
)
@@ -0,0 +1,202 @@
package _default
import (
"fmt"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// defaultPlainTextDataTable defines the structure of ezbookkeeping default plain text data table
type defaultPlainTextDataTable struct {
columnSeparator string
lineSeparator string
allLines []string
headerLineColumnNames []string
}
// defaultPlainTextDataRow defines the structure of ezbookkeeping default plain text data row
type defaultPlainTextDataRow struct {
allItems []string
}
// defaultPlainTextDataRowIterator defines the structure of ezbookkeeping default plain text data row iterator
type defaultPlainTextDataRowIterator struct {
dataTable *defaultPlainTextDataTable
currentIndex int
}
// defaultTransactionPlainTextDataTableBuilder defines the structure of ezbookkeeping default transaction plain text data table builder
type defaultTransactionPlainTextDataTableBuilder struct {
columnSeparator string
lineSeparator string
columns []datatable.TransactionDataTableColumn
dataColumnNameMapping map[datatable.TransactionDataTableColumn]string
dataLineFormat string
builder *strings.Builder
}
// DataRowCount returns the total count of data row
func (t *defaultPlainTextDataTable) DataRowCount() int {
if len(t.allLines) < 1 {
return 0
}
return len(t.allLines) - 1
}
// HeaderColumnNames returns the header column name list
func (t *defaultPlainTextDataTable) HeaderColumnNames() []string {
return t.headerLineColumnNames
}
// DataRowIterator returns the iterator of data row
func (t *defaultPlainTextDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &defaultPlainTextDataRowIterator{
dataTable: t,
currentIndex: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *defaultPlainTextDataRow) ColumnCount() int {
return len(r.allItems)
}
// GetData returns the data in the specified column index
func (r *defaultPlainTextDataRow) GetData(columnIndex int) string {
if columnIndex >= len(r.allItems) {
return ""
}
return r.allItems[columnIndex]
}
// HasNext returns whether the iterator does not reach the end
func (t *defaultPlainTextDataRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.allLines)
}
// CurrentRowId returns current index
func (t *defaultPlainTextDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("line#%d", t.currentIndex)
}
// Next returns the next imported data row
func (t *defaultPlainTextDataRowIterator) Next() datatable.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 &defaultPlainTextDataRow{
allItems: rowItems,
}
}
// AppendTransaction appends the specified transaction to data builder
func (b *defaultTransactionPlainTextDataTableBuilder) AppendTransaction(data map[datatable.TransactionDataTableColumn]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...))
}
// ReplaceDelimiters returns the text after removing the delimiters
func (b *defaultTransactionPlainTextDataTableBuilder) 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, b.columnSeparator, " ", -1)
text = strings.Replace(text, b.lineSeparator, " ", -1)
return text
}
// String returns the textual representation of this data
func (b *defaultTransactionPlainTextDataTableBuilder) String() string {
return b.builder.String()
}
func (b *defaultTransactionPlainTextDataTableBuilder) 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 *defaultTransactionPlainTextDataTableBuilder) 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 createNewDefaultPlainTextDataTable(content string, columnSeparator string, lineSeparator string) (*defaultPlainTextDataTable, error) {
allLines := strings.Split(content, lineSeparator)
if len(allLines) < 2 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
headerLine := allLines[0]
headerLine = strings.ReplaceAll(headerLine, "\r", "")
headerLineItems := strings.Split(headerLine, columnSeparator)
return &defaultPlainTextDataTable{
columnSeparator: columnSeparator,
lineSeparator: lineSeparator,
allLines: allLines,
headerLineColumnNames: headerLineItems,
}, nil
}
func createNewDefaultTransactionPlainTextDataTableBuilder(transactionCount int, columns []datatable.TransactionDataTableColumn, dataColumnNameMapping map[datatable.TransactionDataTableColumn]string, columnSeparator string, lineSeparator string) *defaultTransactionPlainTextDataTableBuilder {
var builder strings.Builder
builder.Grow(transactionCount * 100)
dataTableBuilder := &defaultTransactionPlainTextDataTableBuilder{
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
}
@@ -0,0 +1,231 @@
package dsv
import (
"bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var supportedFileTypeSeparators = map[string]rune{
"custom_csv": ',',
"custom_tsv": '\t',
}
var supportedFileEncodings = map[string]encoding.Encoding{
"utf-8": unicode.UTF8, // UTF-8
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), // UTF-16 Little Endian
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM), // UTF-16 Big Endian
"cp437": charmap.CodePage437, // OEM United States (CP-437)
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
"cp850": charmap.CodePage850, // Western European (CP-850)
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
"cp865": charmap.CodePage865, // North European (CP-865)
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
"cp852": charmap.CodePage852, // Central European (CP-852)
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
"cp860": charmap.CodePage860, // Portuguese (CP-860)
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
"cp862": charmap.CodePage862, // Hebrew (CP-862)
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
"windows-874": charmap.Windows874, // Thai (Windows-874)
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
}
var customTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
}
type CustomTransactionDataDsvFileParser interface {
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
}
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
type customTransactionDataDsvFileImporter struct {
fileEncoding encoding.Encoding
separator rune
columnIndexMapping map[datatable.TransactionDataTableColumn]int
transactionTypeNameMapping map[string]models.TransactionType
hasHeaderLine bool
timeFormat string
timezoneFormat string
amountDecimalSeparator string
amountDigitGroupingSymbol string
geoLocationSeparator string
transactionTagSeparator string
}
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
csvReader := csv.NewReader(reader)
csvReader.Comma = c.separator
csvReader.FieldsPerRecord = -1
allLines := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if len(items) == 1 && items[0] == "" {
continue
}
for index := range items {
items[index] = strings.Trim(items[index], " ")
}
allLines = append(allLines, items)
}
return allLines, nil
}
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
allLines, err := c.ParseDsvFileLines(ctx, data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if !c.hasHeaderLine {
allLines = append([][]string{{}}, allLines...)
}
dataTable := csvconverter.CreateNewCustomCsvImportedDataTable(allLines)
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.transactionTagSeparator)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
// IsDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
func IsDelimiterSeparatedValuesFileType(fileType string) bool {
_, exists := supportedFileTypeSeparators[fileType]
return exists
}
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
separator, exists := supportedFileTypeSeparators[fileType]
if !exists {
return nil, errs.ErrImportFileTypeNotSupported
}
enc, exists := supportedFileEncodings[fileEncoding]
if !exists {
return nil, errs.ErrImportFileEncodingNotSupported
}
return &customTransactionDataDsvFileImporter{
fileEncoding: enc,
separator: separator,
}, nil
}
// CreateNewCustomTransactionDataDsvFileImporter returns a new custom dsv importer for transaction data
func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
separator, exists := supportedFileTypeSeparators[fileType]
if !exists {
return nil, errs.ErrImportFileTypeNotSupported
}
enc, exists := supportedFileEncodings[fileEncoding]
if !exists {
return nil, errs.ErrImportFileEncodingNotSupported
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
return &customTransactionDataDsvFileImporter{
fileEncoding: enc,
separator: separator,
columnIndexMapping: columnIndexMapping,
transactionTypeNameMapping: transactionTypeNameMapping,
hasHeaderLine: hasHeaderLine,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
geoLocationSeparator: geoLocationSeparator,
transactionTagSeparator: transactionTagSeparator,
}, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
package dsv
import (
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// customPlainTextDataTable defines the structure of custom plain text transaction data table
type customPlainTextDataTable struct {
innerDataTable datatable.ImportedDataTable
columnIndexMapping map[datatable.TransactionDataTableColumn]int
transactionTypeNameMapping map[string]models.TransactionType
timeFormat string
timezoneFormat string
timeFormatIncludeTimezone bool
amountDecimalSeparator string
amountDigitGroupingSymbol string
}
// customPlainTextDataRow defines the structure of custom plain text transaction data row
type customPlainTextDataRow struct {
transactionDataTable *customPlainTextDataTable
rowData map[datatable.TransactionDataTableColumn]string
isValid bool
}
// customPlainTextDataRowIterator defines the structure of custom plain text transaction data row iterator
type customPlainTextDataRowIterator struct {
transactionDataTable *customPlainTextDataTable
innerIterator datatable.ImportedDataRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *customPlainTextDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
// custom dsv file allows no sub category, account name and related account name column mapping, but data table converter needs these columns
if column == datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY ||
column == datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME ||
column == datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME {
return true
}
// timezone column will be added when original time format contains timezone
if t.timeFormatIncludeTimezone && column == datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE {
return true
}
_, exists := t.columnIndexMapping[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *customPlainTextDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *customPlainTextDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &customPlainTextDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *customPlainTextDataRow) IsValid() bool {
return r.isValid
}
// GetData returns the data in the specified column type
func (r *customPlainTextDataRow) GetData(column datatable.TransactionDataTableColumn) string {
return r.rowData[column]
}
// HasNext returns whether the iterator does not reach the end
func (t *customPlainTextDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *customPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil, nil
}
rowData, isValid, err := t.parseTransaction(ctx, user, importedRow)
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.Next] cannot parsing transaction in row \"%s\", because %s", t.innerIterator.CurrentRowId(), err.Error())
return nil, err
}
return &customPlainTextDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
isValid: isValid,
}, nil
}
func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user *models.User, row datatable.ImportedDataRow) (map[datatable.TransactionDataTableColumn]string, bool, error) {
rowData := make(map[datatable.TransactionDataTableColumn]string, len(t.transactionDataTable.columnIndexMapping))
for column, columnIndex := range t.transactionDataTable.columnIndexMapping {
if columnIndex < 0 || columnIndex >= row.ColumnCount() {
continue
}
value := row.GetData(columnIndex)
rowData[column] = value
}
// parse transaction type
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != "" {
transactionType, exists := t.transactionDataTable.transactionTypeNameMapping[rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]]
if !exists {
log.Warnf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] skip parsing this transaction, because transaction type \"%s\" mapping not defined", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE])
return nil, false, nil
}
mappedTransactionType, exists := customTransactionTypeNameMapping[transactionType]
if !exists {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction type \"%s\", because type \"%d\" is invalid", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE], transactionType)
return nil, false, errs.ErrTransactionTypeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = mappedTransactionType
}
// parse date time
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
dateTime, err := time.Parse(t.transactionDataTable.timeFormat, rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
if err != nil {
return nil, false, errs.ErrTransactionTimeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
if t.transactionDataTable.timeFormatIncludeTimezone {
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
}
}
// parse timezone
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] != "" {
if t.transactionDataTable.timezoneFormat == "Z" || t.transactionDataTable.timezoneFormat == "" { // -HH:mm
// Do Nothing
} else if t.transactionDataTable.timezoneFormat == "ZZ" { // -HHmm
timezone := rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE]
if len(timezone) != 5 {
return nil, false, errs.ErrTransactionTimeZoneInvalid
}
timezone = timezone[:3] + ":" + timezone[3:]
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
} else {
return nil, false, errs.ErrImportFileTransactionTimezoneFormatInvalid
}
}
// use primary category if sub category is empty
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY]
}
// trim trailing zero in decimal
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
amount, err := t.parseAmount(ctx, rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT], err.Error())
return nil, false, err
}
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = amount
}
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
amount, err := t.parseAmount(ctx, rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction related amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT], err.Error())
return nil, false, err
}
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = amount
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
return rowData, true, nil
}
func (t *customPlainTextDataRowIterator) parseAmount(ctx core.Context, amountValue string) (string, error) {
if t.transactionDataTable.amountDigitGroupingSymbol != "" {
amountValue = strings.ReplaceAll(amountValue, t.transactionDataTable.amountDigitGroupingSymbol, "")
}
if t.transactionDataTable.amountDecimalSeparator != "" && t.transactionDataTable.amountDecimalSeparator != "." {
if strings.Contains(amountValue, ".") {
return "", errs.ErrAmountInvalid
}
amountValue = strings.ReplaceAll(amountValue, t.transactionDataTable.amountDecimalSeparator, ".")
}
amountValue = utils.TrimTrailingZerosInDecimal(amountValue)
amount, err := utils.ParseAmount(amountValue)
if err != nil {
return "", errs.ErrAmountInvalid
}
return utils.FormatAmount(amount), nil
}
// CreateNewCustomPlainTextDataTable returns transaction data table from imported data table
func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string) *customPlainTextDataTable {
timeFormatIncludeTimezone := strings.Contains(timeFormat, "z") || strings.Contains(timeFormat, "Z")
return &customPlainTextDataTable{
innerDataTable: dataTable,
columnIndexMapping: columnIndexMapping,
transactionTypeNameMapping: transactionTypeNameMapping,
timeFormat: getDateTimeFormat(timeFormat),
timezoneFormat: timezoneFormat,
timeFormatIncludeTimezone: timeFormatIncludeTimezone,
amountDecimalSeparator: amountDecimalSeparator,
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
}
}
func getDateTimeFormat(format string) string {
// convert moment.js format to Go format
format = strings.ReplaceAll(format, "YYYY", "2006")
format = strings.ReplaceAll(format, "YY", "06")
format = strings.ReplaceAll(format, "MMMM", "January")
format = strings.ReplaceAll(format, "MMM", "Jan")
format = strings.ReplaceAll(format, "MM", "01")
format = strings.ReplaceAll(format, "M", "1")
format = strings.ReplaceAll(format, "DD", "02")
format = strings.ReplaceAll(format, "D", "2")
format = strings.ReplaceAll(format, "dddd", "Monday")
format = strings.ReplaceAll(format, "ddd", "Mon")
format = strings.ReplaceAll(format, "HH", "15")
format = strings.ReplaceAll(format, "H", "15")
format = strings.ReplaceAll(format, "hh", "03")
format = strings.ReplaceAll(format, "h", "3")
format = strings.ReplaceAll(format, "mm", "04")
format = strings.ReplaceAll(format, "m", "4")
format = strings.ReplaceAll(format, "ss", "05")
format = strings.ReplaceAll(format, "s", "5")
for i := 9; i >= 1; i-- {
format = strings.ReplaceAll(format, "."+strings.Repeat("S", i), "."+strings.Repeat("9", i))
}
format = strings.ReplaceAll(format, "A", "PM")
format = strings.ReplaceAll(format, "a", "pm")
format = strings.ReplaceAll(format, "zz", "MST")
format = strings.ReplaceAll(format, "z", "MST")
if strings.Contains(format, "ZZ") {
format = strings.ReplaceAll(format, "ZZ", "Z0700")
} else if strings.Contains(format, "Z") {
format = strings.ReplaceAll(format, "Z", "Z07:00")
}
return format
}
@@ -0,0 +1,191 @@
package excel
import (
"bytes"
"fmt"
"github.com/extrame/xls"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// ExcelMSCFBFileImportedDataTable defines the structure of excel (microsoft compound file binary) file data table
type ExcelMSCFBFileImportedDataTable struct {
workbook *xls.WorkBook
headerLineColumnNames []string
}
// ExcelMSCFBFileDataRow defines the structure of excel (microsoft compound file binary) file data table row
type ExcelMSCFBFileDataRow struct {
sheet *xls.WorkSheet
rowIndex int
}
// ExcelMSCFBFileDataRowIterator defines the structure of excel (microsoft compound file binary) file data table row iterator
type ExcelMSCFBFileDataRowIterator struct {
dataTable *ExcelMSCFBFileImportedDataTable
currentSheetIndex int
currentRowIndexInSheet uint16
}
// DataRowCount returns the total count of data row
func (t *ExcelMSCFBFileImportedDataTable) DataRowCount() int {
totalDataRowCount := 0
for i := 0; i < t.workbook.NumSheets(); i++ {
sheet := t.workbook.GetSheet(i)
if sheet.MaxRow < 1 {
continue
}
totalDataRowCount += int(sheet.MaxRow)
}
return totalDataRowCount
}
// HeaderColumnNames returns the header column name list
func (t *ExcelMSCFBFileImportedDataTable) HeaderColumnNames() []string {
return t.headerLineColumnNames
}
// DataRowIterator returns the iterator of data row
func (t *ExcelMSCFBFileImportedDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &ExcelMSCFBFileDataRowIterator{
dataTable: t,
currentSheetIndex: 0,
currentRowIndexInSheet: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *ExcelMSCFBFileDataRow) ColumnCount() int {
row := r.sheet.Row(r.rowIndex)
return row.LastCol() + 1
}
// GetData returns the data in the specified column index
func (r *ExcelMSCFBFileDataRow) GetData(columnIndex int) string {
row := r.sheet.Row(r.rowIndex)
return row.Col(columnIndex)
}
// HasNext returns whether the iterator does not reach the end
func (t *ExcelMSCFBFileDataRowIterator) HasNext() bool {
workbook := t.dataTable.workbook
if t.currentSheetIndex >= workbook.NumSheets() {
return false
}
currentSheet := workbook.GetSheet(t.currentSheetIndex)
if t.currentRowIndexInSheet+1 <= currentSheet.MaxRow {
return true
}
for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i)
if sheet.MaxRow < 1 {
continue
}
return true
}
return false
}
// CurrentRowId returns current index
func (t *ExcelMSCFBFileDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("table#%d-row#%d", t.currentSheetIndex, t.currentRowIndexInSheet)
}
// Next returns the next imported data row
func (t *ExcelMSCFBFileDataRowIterator) Next() datatable.ImportedDataRow {
workbook := t.dataTable.workbook
currentRowIndexInTable := t.currentRowIndexInSheet
for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i)
if currentRowIndexInTable+1 <= sheet.MaxRow {
t.currentRowIndexInSheet++
currentRowIndexInTable = t.currentRowIndexInSheet
break
}
t.currentSheetIndex++
t.currentRowIndexInSheet = 0
currentRowIndexInTable = 0
}
if t.currentSheetIndex >= workbook.NumSheets() {
return nil
}
currentSheet := workbook.GetSheet(t.currentSheetIndex)
if t.currentRowIndexInSheet > currentSheet.MaxRow {
return nil
}
return &ExcelMSCFBFileDataRow{
sheet: currentSheet,
rowIndex: int(t.currentRowIndexInSheet),
}
}
// CreateNewExcelMSCFBFileImportedDataTable returns excel (microsoft compound file binary) data table by file binary data
func CreateNewExcelMSCFBFileImportedDataTable(data []byte) (*ExcelMSCFBFileImportedDataTable, error) {
reader := bytes.NewReader(data)
workbook, err := xls.OpenReader(reader, "")
if err != nil {
return nil, err
}
var headerRowItems []string
for i := 0; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i)
if sheet.MaxRow < 0 {
continue
}
row := sheet.Row(0)
if row == nil {
continue
}
if i == 0 {
for j := 0; j <= row.LastCol(); j++ {
headerItem := row.Col(j)
if headerItem == "" {
break
}
headerRowItems = append(headerRowItems, headerItem)
}
} else {
for j := 0; j <= min(row.LastCol(), len(headerRowItems)-1); j++ {
headerItem := row.Col(j)
if headerItem != headerRowItems[j] {
return nil, errs.ErrFieldsInMultiTableAreDifferent
}
}
}
}
return &ExcelMSCFBFileImportedDataTable{
workbook: workbook,
headerLineColumnNames: headerRowItems,
}, nil
}
@@ -0,0 +1,246 @@
package excel
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestExcelMSCFBFileImportedDataTableDataRowCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
}
func TestExcelMSCFBFileImportedDataTableDataRowCount_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 5, datatable.DataRowCount())
}
func TestExcelMSCFBFileImportedDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestExcelMSCFBFileImportedDataTableDataRowCount_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestExcelMSCFBFileImportedDataTableHeaderColumnNames(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestExcelMSCFBFileImportedDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestExcelMSCFBFileDataRowIterator(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 3
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelMSCFBFileDataRowIterator_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// sheet 1 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 1 data row 2
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 3 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 5 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 5 data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelMSCFBFileDataRowIterator_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext())
// not existed data row 1
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 2
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelMSCFBFileDataRowIterator_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext())
// not existed data row 1
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 2
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelMSCFBFileDataRowColumnCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.EqualValues(t, 4, row1.ColumnCount())
row2 := iterator.Next()
assert.EqualValues(t, 4, row2.ColumnCount())
}
func TestExcelMSCFBFileDataRowGetData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
}
func TestExcelMSCFBFileDataRowGetData_GetNotExistedColumnData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "", row1.GetData(3))
}
func TestExcelMSCFBFileDataRowGetData_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next()
assert.Equal(t, "1-A2", sheet1Row1.GetData(0))
assert.Equal(t, "1-B2", sheet1Row1.GetData(1))
assert.Equal(t, "1-C2", sheet1Row1.GetData(2))
sheet1Row2 := iterator.Next()
assert.Equal(t, "1-A3", sheet1Row2.GetData(0))
assert.Equal(t, "1-B3", sheet1Row2.GetData(1))
assert.Equal(t, "1-C3", sheet1Row2.GetData(2))
// skip empty sheet2
sheet3Row1 := iterator.Next()
assert.Equal(t, "3-A2", sheet3Row1.GetData(0))
assert.Equal(t, "3-B2", sheet3Row1.GetData(1))
assert.Equal(t, "", sheet3Row1.GetData(2))
// skip no data row sheet4
sheet5Row1 := iterator.Next()
assert.Equal(t, "5-A2", sheet5Row1.GetData(0))
assert.Equal(t, "5-B2", sheet5Row1.GetData(1))
assert.Equal(t, "5-C2", sheet5Row1.GetData(2))
sheet5Row2 := iterator.Next()
assert.Equal(t, "5-A3", sheet5Row2.GetData(0))
assert.Equal(t, "5-B3", sheet5Row2.GetData(1))
assert.Equal(t, "5-C3", sheet5Row2.GetData(2))
}
func TestCreateNewExcelMSCFBFileImportedDataTable_MultipleSheetsWithDifferentHeaders(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
assert.Nil(t, err)
_, err = CreateNewExcelMSCFBFileImportedDataTable(testdata)
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
}
@@ -0,0 +1,211 @@
package excel
import (
"bytes"
"fmt"
"github.com/xuri/excelize/v2"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
// excelOOXMLSheet defines the structure of excel (Office Open XML) file sheet
type excelOOXMLSheet struct {
sheetName string
allData [][]string
}
// ExcelOOXMLFileImportedDataTable defines the structure of excel (Office Open XML) file data table
type ExcelOOXMLFileImportedDataTable struct {
sheets []*excelOOXMLSheet
headerLineColumnNames []string
}
// ExcelOOXMLFileDataRow defines the structure of excel (Office Open XML) file data table row
type ExcelOOXMLFileDataRow struct {
sheet *excelOOXMLSheet
rowData []string
rowIndex int
}
// ExcelOOXMLFileDataRowIterator defines the structure of excel (Office Open XML) file data table row iterator
type ExcelOOXMLFileDataRowIterator struct {
dataTable *ExcelOOXMLFileImportedDataTable
currentSheetIndex int
currentRowIndexInSheet int
}
// DataRowCount returns the total count of data row
func (t *ExcelOOXMLFileImportedDataTable) DataRowCount() int {
totalDataRowCount := 0
for i := 0; i < len(t.sheets); i++ {
sheet := t.sheets[i]
if len(sheet.allData) < 1 {
continue
}
totalDataRowCount += len(sheet.allData) - 1
}
return totalDataRowCount
}
// HeaderColumnNames returns the header column name list
func (t *ExcelOOXMLFileImportedDataTable) HeaderColumnNames() []string {
return t.headerLineColumnNames
}
// DataRowIterator returns the iterator of data row
func (t *ExcelOOXMLFileImportedDataTable) DataRowIterator() datatable.ImportedDataRowIterator {
return &ExcelOOXMLFileDataRowIterator{
dataTable: t,
currentSheetIndex: 0,
currentRowIndexInSheet: 0,
}
}
// ColumnCount returns the total count of column in this data row
func (r *ExcelOOXMLFileDataRow) ColumnCount() int {
return len(r.rowData)
}
// GetData returns the data in the specified column index
func (r *ExcelOOXMLFileDataRow) GetData(columnIndex int) string {
if columnIndex < 0 || columnIndex >= len(r.rowData) {
return ""
}
return r.rowData[columnIndex]
}
// HasNext returns whether the iterator does not reach the end
func (t *ExcelOOXMLFileDataRowIterator) HasNext() bool {
sheets := t.dataTable.sheets
if t.currentSheetIndex >= len(sheets) {
return false
}
currentSheet := sheets[t.currentSheetIndex]
if t.currentRowIndexInSheet+1 < len(currentSheet.allData) {
return true
}
for i := t.currentSheetIndex + 1; i < len(sheets); i++ {
sheet := sheets[i]
if len(sheet.allData) <= 1 {
continue
}
return true
}
return false
}
// CurrentRowId returns current index
func (t *ExcelOOXMLFileDataRowIterator) CurrentRowId() string {
return fmt.Sprintf("table#%d-row#%d", t.currentSheetIndex, t.currentRowIndexInSheet)
}
// Next returns the next imported data row
func (t *ExcelOOXMLFileDataRowIterator) Next() datatable.ImportedDataRow {
sheets := t.dataTable.sheets
currentRowIndexInTable := t.currentRowIndexInSheet
for i := t.currentSheetIndex; i < len(sheets); i++ {
sheet := sheets[i]
if currentRowIndexInTable+1 < len(sheet.allData) {
t.currentRowIndexInSheet++
currentRowIndexInTable = t.currentRowIndexInSheet
break
}
t.currentSheetIndex++
t.currentRowIndexInSheet = 0
currentRowIndexInTable = 0
}
if t.currentSheetIndex >= len(sheets) {
return nil
}
currentSheet := sheets[t.currentSheetIndex]
if t.currentRowIndexInSheet >= len(currentSheet.allData) {
return nil
}
return &ExcelOOXMLFileDataRow{
sheet: currentSheet,
rowData: currentSheet.allData[t.currentRowIndexInSheet],
rowIndex: t.currentRowIndexInSheet,
}
}
// CreateNewExcelOOXMLFileImportedDataTable returns excel (Office Open XML) data table by file binary data
func CreateNewExcelOOXMLFileImportedDataTable(data []byte) (*ExcelOOXMLFileImportedDataTable, error) {
reader := bytes.NewReader(data)
file, err := excelize.OpenReader(reader)
defer file.Close()
if err != nil {
return nil, err
}
sheetNames := file.GetSheetList()
var headerRowItems []string
var sheets []*excelOOXMLSheet
for i := 0; i < len(sheetNames); i++ {
sheetName := sheetNames[i]
allData, err := file.GetRows(sheetName)
if err != nil {
return nil, err
}
if allData == nil || len(allData) < 1 {
continue
}
row := allData[0]
if i == 0 {
for j := 0; j < len(row); j++ {
headerItem := row[j]
if headerItem == "" {
break
}
headerRowItems = append(headerRowItems, headerItem)
}
} else {
for j := 0; j < min(len(row), len(headerRowItems)); j++ {
headerItem := row[j]
if headerItem != headerRowItems[j] {
return nil, errs.ErrFieldsInMultiTableAreDifferent
}
}
}
sheets = append(sheets, &excelOOXMLSheet{
sheetName: sheetName,
allData: allData,
})
}
return &ExcelOOXMLFileImportedDataTable{
sheets: sheets,
headerLineColumnNames: headerRowItems,
}, nil
}
@@ -0,0 +1,246 @@
package excel
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
func TestExcelOOXMLFileImportedDataTableDataRowCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount())
}
func TestExcelOOXMLFileImportedDataTableDataRowCount_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 5, datatable.DataRowCount())
}
func TestExcelOOXMLFileImportedDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestExcelOOXMLFileImportedDataTableDataRowCount_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
}
func TestExcelOOXMLFileImportedDataTableHeaderColumnNames(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
}
func TestExcelOOXMLFileImportedDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestExcelOOXMLFileDataRowIterator(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 3
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelOOXMLFileDataRowIterator_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext())
// sheet 1 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 1 data row 2
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 3 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 5 data row 1
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 5 data row 2
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelOOXMLFileDataRowIterator_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext())
// not existed data row 1
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 2
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelOOXMLFileDataRowIterator_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext())
// not existed data row 1
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 2
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelOOXMLFileDataRowColumnCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.EqualValues(t, 3, row1.ColumnCount())
row2 := iterator.Next()
assert.EqualValues(t, 3, row2.ColumnCount())
}
func TestExcelOOXMLFileDataRowGetData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A2", row1.GetData(0))
assert.Equal(t, "B2", row1.GetData(1))
assert.Equal(t, "C2", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A3", row2.GetData(0))
assert.Equal(t, "B3", row2.GetData(1))
assert.Equal(t, "C3", row2.GetData(2))
}
func TestExcelOOXMLFileDataRowGetData_GetNotExistedColumnData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "", row1.GetData(3))
}
func TestExcelOOXMLFileDataRowGetData_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileImportedDataTable(testdata)
iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next()
assert.Equal(t, "1-A2", sheet1Row1.GetData(0))
assert.Equal(t, "1-B2", sheet1Row1.GetData(1))
assert.Equal(t, "1-C2", sheet1Row1.GetData(2))
sheet1Row2 := iterator.Next()
assert.Equal(t, "1-A3", sheet1Row2.GetData(0))
assert.Equal(t, "1-B3", sheet1Row2.GetData(1))
assert.Equal(t, "1-C3", sheet1Row2.GetData(2))
// skip empty sheet2
sheet3Row1 := iterator.Next()
assert.Equal(t, "3-A2", sheet3Row1.GetData(0))
assert.Equal(t, "3-B2", sheet3Row1.GetData(1))
assert.Equal(t, "", sheet3Row1.GetData(2))
// skip no data row sheet4
sheet5Row1 := iterator.Next()
assert.Equal(t, "5-A2", sheet5Row1.GetData(0))
assert.Equal(t, "5-B2", sheet5Row1.GetData(1))
assert.Equal(t, "5-C2", sheet5Row1.GetData(2))
sheet5Row2 := iterator.Next()
assert.Equal(t, "5-A3", sheet5Row2.GetData(0))
assert.Equal(t, "5-B3", sheet5Row2.GetData(1))
assert.Equal(t, "5-C3", sheet5Row2.GetData(2))
}
func TestCreateNewExcelOOXMLFileImportedDataTable_MultipleSheetsWithDifferentHeaders(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
assert.Nil(t, err)
_, err = CreateNewExcelOOXMLFileImportedDataTable(testdata)
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
}
-179
View File
@@ -1,179 +0,0 @@
package converters
import (
"fmt"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// EzBookKeepingCSVFileExporter defines the structure of csv file exporter
type EzBookKeepingCSVFileExporter struct {
DataConverter
}
const csvHeaderLine = "Time,Timezone,Type,Category,Sub Category,Account,Account Currency,Amount,Account2,Account2 Currency,Account2 Amount,Tags,Comment\n"
const csvDataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n"
// ToExportedContent returns the exported csv data
func (e *EzBookKeepingCSVFileExporter) ToExportedContent(uid int64, timezone *time.Location, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexs map[int64][]int64) ([]byte, error) {
var ret strings.Builder
ret.Grow(len(transactions) * 100)
ret.WriteString(csvHeaderLine)
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.FormatUnixTimeToLongDateTimeWithoutSecond(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime), transactionTimeZone)
transactionTimezone := utils.FormatTimezoneOffset(transactionTimeZone)
transactionType := e.getTransactionTypeName(transaction.Type)
category := e.getTransactionCategoryName(transaction.CategoryId, categoryMap)
subCategory := e.getTransactionSubCategoryName(transaction.CategoryId, categoryMap)
account := e.getAccountName(transaction.AccountId, accountMap)
accountCurrency := e.getAccountCurrency(transaction.AccountId, accountMap)
amount := e.getDisplayAmount(transaction.Amount)
account2 := ""
account2Currency := ""
account2Amount := ""
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
account2 = e.getAccountName(transaction.RelatedAccountId, accountMap)
account2Currency = e.getAccountCurrency(transaction.RelatedAccountId, accountMap)
account2Amount = e.getDisplayAmount(transaction.RelatedAccountAmount)
}
tags := e.getTags(transaction.TransactionId, allTagIndexs, tagMap)
comment := e.getComment(transaction.Comment)
ret.WriteString(fmt.Sprintf(csvDataLineFormat, transactionTime, transactionTimezone, transactionType, category, subCategory, account, accountCurrency, amount, account2, account2Currency, account2Amount, tags, comment))
}
return []byte(ret.String()), nil
}
func (e *EzBookKeepingCSVFileExporter) 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 *EzBookKeepingCSVFileExporter) 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 *EzBookKeepingCSVFileExporter) getTransactionSubCategoryName(categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
category, exists := categoryMap[categoryId]
if exists {
return category.Name
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getAccountName(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return account.Name
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getAccountCurrency(accountId int64, accountMap map[int64]*models.Account) string {
account, exists := accountMap[accountId]
if exists {
return account.Currency
} else {
return ""
}
}
func (e *EzBookKeepingCSVFileExporter) getDisplayAmount(amount int64) string {
displayAmount := utils.Int64ToString(amount)
integer := utils.SubString(displayAmount, 0, len(displayAmount)-2)
decimals := utils.SubString(displayAmount, -2, 2)
if integer == "" {
integer = "0"
} else if integer == "-" {
integer = "-0"
}
if len(decimals) == 0 {
decimals = "00"
} else if len(decimals) == 1 {
decimals = "0" + decimals
}
return integer + "." + decimals
}
func (e *EzBookKeepingCSVFileExporter) getTags(transactionId int64, allTagIndexs map[int64][]int64, tagMap map[int64]*models.TransactionTag) string {
tagIndexs, exists := allTagIndexs[transactionId]
if !exists {
return ""
}
var ret strings.Builder
for i := 0; i < len(tagIndexs); i++ {
if i > 0 {
ret.WriteString(";")
}
tagIndex := tagIndexs[i]
tag, exists := tagMap[tagIndex]
if !exists {
continue
}
ret.WriteString(tag.Name)
}
return ret.String()
}
func (e *EzBookKeepingCSVFileExporter) getComment(comment string) string {
comment = strings.Replace(comment, ",", " ", -1)
comment = strings.Replace(comment, "\r\n", " ", -1)
comment = strings.Replace(comment, "\n", " ", -1)
return comment
}
@@ -0,0 +1,273 @@
package feidee
import (
"bytes"
"encoding/csv"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"io"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
const feideeMymoneyAppTransactionDataCsvFileHeader = "随手记导出文件(headers:v5;"
const feideeMymoneyAppTransactionTimeColumnName = "日期"
const feideeMymoneyAppTransactionTypeColumnName = "交易类型"
const feideeMymoneyAppTransactionCategoryColumnName = "类别"
const feideeMymoneyAppTransactionSubCategoryColumnName = "子类别"
const feideeMymoneyAppTransactionAccountNameColumnName = "账户"
const feideeMymoneyAppTransactionAccountCurrencyColumnName = "账户币种"
const feideeMymoneyAppTransactionAmountColumnName = "金额"
const feideeMymoneyAppTransactionDescriptionColumnName = "备注"
const feideeMymoneyAppTransactionRelatedIdColumnName = "关联Id"
const feideeMymoneyAppTransactionTypeModifyBalanceText = "余额变更"
const feideeMymoneyAppTransactionTypeModifyOutstandingBalanceText = "负债变更"
const feideeMymoneyAppTransactionTypeIncomeText = "收入"
const feideeMymoneyAppTransactionTypeExpenseText = "支出"
const feideeMymoneyAppTransactionTypeTransferInText = "转入"
const feideeMymoneyAppTransactionTypeTransferOutText = "转出"
var feideeMymoneyAppDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: feideeMymoneyAppTransactionTimeColumnName,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: feideeMymoneyAppTransactionTypeColumnName,
datatable.TRANSACTION_DATA_TABLE_CATEGORY: feideeMymoneyAppTransactionCategoryColumnName,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: feideeMymoneyAppTransactionSubCategoryColumnName,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: feideeMymoneyAppTransactionAccountNameColumnName,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: feideeMymoneyAppTransactionAccountCurrencyColumnName,
datatable.TRANSACTION_DATA_TABLE_AMOUNT: feideeMymoneyAppTransactionAmountColumnName,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: feideeMymoneyAppTransactionDescriptionColumnName,
}
// feideeMymoneyAppTransactionDataCsvFileImporter defines the structure of feidee mymoney app csv importer for transaction data
type feideeMymoneyAppTransactionDataCsvFileImporter struct{}
// Initialize a feidee mymoney app transaction data csv file importer singleton instance
var (
FeideeMymoneyAppTransactionDataCsvFileImporter = &feideeMymoneyAppTransactionDataCsvFileImporter{}
)
// ParseImportedData returns the imported data by parsing the feidee mymoney app transaction csv data
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
dataTable, err := c.createNewFeideeMymoneyAppImportedDataTable(ctx, reader)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewImportedCommonDataTable(dataTable)
if !commonDataTable.HasColumn(feideeMymoneyAppTransactionTimeColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionTypeColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionSubCategoryColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionAccountNameColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionAmountColumnName) ||
!commonDataTable.HasColumn(feideeMymoneyAppTransactionRelatedIdColumnName) {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.ParseImportedData] cannot parse import data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionDataTable, err := c.createNewFeideeMymoneyAppTransactionDataTable(ctx, commonDataTable)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(feideeMymoneyTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppImportedDataTable(ctx core.Context, reader io.Reader) (datatable.ImportedDataTable, error) {
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
allOriginalLines := make([][]string, 0)
hasFileHeader := false
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse feidee mymoney csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
continue
}
}
allOriginalLines = append(allOriginalLines, items)
}
if !hasFileHeader {
return nil, errs.ErrInvalidFileHeader
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvImportedDataTable(allOriginalLines)
return dataTable, nil
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) {
newColumns := make([]datatable.TransactionDataTableColumn, 0, 11)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionCategoryColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_CATEGORY)
}
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionAccountCurrencyColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY)
}
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_AMOUNT)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionAccountCurrencyColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY)
}
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT)
if commonDataTable.HasColumn(feideeMymoneyAppTransactionDescriptionColumnName) {
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_DESCRIPTION)
}
transactionRowParser := createFeideeMymoneyTransactionDataRowParser()
transactionDataTable := datatable.CreateNewWritableTransactionDataTableWithRowParser(newColumns, transactionRowParser)
transferTransactionsMap := make(map[string]map[datatable.TransactionDataTableColumn]string, 0)
commonDataTableIterator := commonDataTable.DataRowIterator()
for commonDataTableIterator.HasNext() {
dataRow := commonDataTableIterator.Next()
rowId := commonDataTableIterator.CurrentRowId()
if dataRow.ColumnCount() < commonDataTable.HeaderColumnCount() {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", rowId, dataRow.ColumnCount(), commonDataTable.HeaderColumnCount())
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
data := make(map[datatable.TransactionDataTableColumn]string, 11)
for columnType, columnName := range feideeMymoneyAppDataColumnNameMapping {
if dataRow.HasData(columnName) {
data[columnType] = dataRow.GetData(columnName)
}
}
transactionType := data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]
if transactionType == feideeMymoneyAppTransactionTypeModifyBalanceText || transactionType == feideeMymoneyAppTransactionTypeModifyOutstandingBalanceText ||
transactionType == feideeMymoneyAppTransactionTypeIncomeText || transactionType == feideeMymoneyAppTransactionTypeExpenseText {
if transactionType == feideeMymoneyAppTransactionTypeModifyBalanceText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
} else if transactionType == feideeMymoneyAppTransactionTypeModifyOutstandingBalanceText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeModifyOutstandingBalanceName
} else if transactionType == feideeMymoneyAppTransactionTypeIncomeText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
} else if transactionType == feideeMymoneyAppTransactionTypeExpenseText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
}
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = ""
transactionDataTable.Add(data)
} else if transactionType == feideeMymoneyAppTransactionTypeTransferInText || transactionType == feideeMymoneyAppTransactionTypeTransferOutText {
relatedId := ""
if dataRow.HasData(feideeMymoneyAppTransactionRelatedIdColumnName) {
relatedId = dataRow.GetData(feideeMymoneyAppTransactionRelatedIdColumnName)
}
if relatedId == "" {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] transfer transaction has blank related id in row \"%s\"", rowId)
return nil, errs.ErrRelatedIdCannotBeBlank
}
relatedData, exists := transferTransactionsMap[relatedId]
if !exists {
transferTransactionsMap[relatedId] = data
continue
}
if transactionType == feideeMymoneyAppTransactionTypeTransferInText && relatedData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == feideeMymoneyAppTransactionTypeTransferOutText {
relatedData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
relatedData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
relatedData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
relatedData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = data[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
transactionDataTable.Add(relatedData)
delete(transferTransactionsMap, relatedId)
} else if transactionType == feideeMymoneyAppTransactionTypeTransferOutText && relatedData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == feideeMymoneyAppTransactionTypeTransferInText {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = feideeMymoneyTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = relatedData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = relatedData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
transactionDataTable.Add(data)
delete(transferTransactionsMap, relatedId)
} else {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] transfer transaction type \"%s\" is not expected in row \"%s\"", transactionType, rowId)
return nil, errs.ErrTransactionTypeInvalid
}
} else {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse transaction type \"%s\" in row \"%s\"", transactionType, rowId)
return nil, errs.ErrTransactionTypeInvalid
}
}
if len(transferTransactionsMap) > 0 {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] there are %d transactions (related id is %s) which don't have related records", len(transferTransactionsMap), c.getFeideeMymoneyAppRelatedTransactionIds(transferTransactionsMap))
return nil, errs.ErrFoundRecordNotHasRelatedRecord
}
return transactionDataTable, nil
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) getFeideeMymoneyAppRelatedTransactionIds(transferTransactionsMap map[string]map[datatable.TransactionDataTableColumn]string) string {
builder := strings.Builder{}
for relatedId := range transferTransactionsMap {
if builder.Len() > 0 {
builder.WriteRune(',')
}
builder.WriteString(relatedId)
}
return builder.String()
}
@@ -0,0 +1,414 @@
package feidee
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
func TestFeideeMymoneyCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"+
"\"余额变更\",\"2024-09-01 01:00:00\",\"\",\"Test Account2\",\"-0.12\",\"\",\"\"\n"+
"\"收入\",\"2024-09-01 01:23:45\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\"\n"+
"\"支出\",\"2024-09-01 12:34:56\",\"Test Category2\",\"Test Account\",\"1.00\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account2\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\"\n"+
"\"转出\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account2\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 6, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 2, len(allNewSubExpenseCategories))
assert.Equal(t, 2, len(allNewSubIncomeCategories))
assert.Equal(t, 1, len(allNewSubTransferCategories))
assert.Equal(t, 0, len(allNewTags))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, 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_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[2].Type)
assert.Equal(t, "2024-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[2].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[3].Type)
assert.Equal(t, "2024-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
assert.Equal(t, int64(100), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
assert.Equal(t, "2024-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime), time.UTC))
assert.Equal(t, int64(5), allNewTransactions[4].Amount)
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", allNewTransactions[4].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
assert.Equal(t, "2024-09-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime), time.UTC))
assert.Equal(t, int64(50), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalDestinationAccountName)
assert.Equal(t, "Test Category3", 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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[1].Uid)
assert.Equal(t, "Test Category2", allNewSubExpenseCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[1].Uid)
assert.Equal(t, "Test Category", allNewSubIncomeCategories[1].Name)
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
assert.Equal(t, "Test Category3", allNewSubTransferCategories[0].Name)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseOutstandingBalanceModification(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"负债变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"+
"\"负债变更\",\"2024-09-01 01:00:00\",\"\",\"Test Account2\",\"-0.12\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, 1, len(allNewSubExpenseCategories))
assert.Equal(t, 1, len(allNewSubIncomeCategories))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, 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:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].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), allNewSubExpenseCategories[0].Uid)
assert.Equal(t, "", allNewSubExpenseCategories[0].Name)
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
assert.Equal(t, "", allNewSubIncomeCategories[0].Name)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"收入\",\"2024-09-01T12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"收入\",\"09/01/2024 12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"Type\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"0.12\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseValidAccountCurrency(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"USD\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, 2, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "USD", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "EUR", allNewAccounts[1].Currency)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAccountCurrency(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"CNY\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"USD\",\"123.45\",\"\",\"\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account2\",\"CNY\",\"1.23\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category3\",\"Test Account\",\"EUR\",\"1.10\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseNotSupportedCurrency(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"XXX\",\"123.45\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"USD\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"XXX\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"账户币种\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"XXX\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"USD\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"123 45\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"负债变更\",\"2024-09-01 01:23:45\",\"\",\"Test Account\",\"123 45\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123 45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account2\",\"123 45\",\"\",\"00000000-0000-0000-0000-000000000001\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"支出\",\"2024-09-01 12:34:56\",\"Test Category\",\"Test Account\",\"123.45\",\"Test\n"+
"A new line break\",\"\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test\nA new line break", allNewTransactions[0].Comment)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_InvalidRelatedId(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrRelatedIdCannotBeBlank.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转入\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrRelatedIdCannotBeBlank.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"转出\",\"2024-09-01 23:59:59\",\"Test Category3\",\"Test Account\",\"0.05\",\"\",\"00000000-0000-0000-0000-000000000001\"\n"+
"\"转入\",\"2024-09-02 23:59:59\",\"Test Category3\",\"Test Account\",\"0.5\",\"\",\"00000000-0000-0000-0000-000000000002\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrFoundRecordNotHasRelatedRecord.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
}
func TestFeideeMymoneyCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := FeideeMymoneyAppTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"2024-09-01 00:00:00\",\"Test Category\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Sub Category Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"账户\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"Test Account\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"金额\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"123.45\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"备注\",\"关联Id\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Related ID Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("随手记导出文件(headers:v5;xxxxx)\n"+
"\"交易类型\",\"日期\",\"子类别\",\"账户\",\"金额\",\"备注\"\n"+
"\"余额变更\",\"2024-09-01 00:00:00\",\"\",\"Test Account\",\"123.45\",\"\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}

Some files were not shown because too many files have changed in this diff Show More