Compare commits

...

224 Commits

Author SHA1 Message Date
MaysWind 4d9643dcb2 raise an error if the file does not exist when uploading an artifact 2025-09-27 17:20:45 +08:00
MaysWind 5dc4ad60ba remove unnecessary parameter 2025-09-27 17:12:41 +08:00
MaysWind 2e5dd7d513 add content encoding to the response headers when the server returns js, csv or tsv 2025-09-27 17:00:42 +08:00
MaysWind efe088f591 update README.md 2025-09-27 16:19:27 +08:00
MaysWind d5016e853e update badges 2025-09-27 16:00:47 +08:00
MaysWind d334bd7b9a automatically create release via github action 2025-09-27 16:00:41 +08:00
MaysWind 21edf0157a modify file name 2025-09-27 15:59:45 +08:00
MaysWind 388167705a build package for windows via github actions 2025-09-27 15:59:14 +08:00
MaysWind 2423b37cbb update uploading artifact job 2025-09-27 14:46:48 +08:00
MaysWind 786796d457 update uploading artifact job 2025-09-27 08:14:36 +08:00
MaysWind 0ed9216260 create tar.gz packages via github actions 2025-09-26 23:20:09 +08:00
MaysWind eb13f10121 move enable_two_factor, enable_forget_password and forget_password_require_email_verify option to the auth section 2025-09-26 21:51:26 +08:00
MaysWind 76ce6f6f9c fix incorrect comment 2025-09-26 21:34:34 +08:00
MaysWind eb305139f5 fix the locale settings on the profile page does not immediately update the preview 2025-09-25 23:32:02 +08:00
MaysWind 8df73f202a update splash screen image file name 2025-09-24 23:11:50 +08:00
MaysWind c22751de6f add new translation contributor 2025-09-24 23:11:41 +08:00
MaysWind c3f1cb0c61 update transaction 2025-09-24 23:11:27 +08:00
MaysWind fc1fc58aa1 update locale default settings 2025-09-24 22:40:38 +08:00
Natthavat Sukdumrongpreecha e4b5e96534 fix: update var name from en to th 2025-09-24 12:25:31 +08:00
Natthavat Sukdumrongpreecha 66303a8965 feat: translate to thai language 2025-09-24 12:25:31 +08:00
MaysWind 9589dd2486 add github issue template 2025-09-23 01:39:18 +08:00
MaysWind 3d5b887e23 support Google AI LLM provider 2025-09-23 00:50:44 +08:00
MaysWind b967a214cb code refactor 2025-09-23 00:28:17 +08:00
MaysWind 5a9877588f improve date time recognition in AI image recognition 2025-09-22 22:21:35 +08:00
MaysWind fc5f8e4633 update go.mod 2025-09-22 22:04:34 +08:00
MaysWind 028bca50ea renamed structs and interfaces to reduce ambiguity 2025-09-22 22:04:06 +08:00
MaysWind 6853bbfb68 renamed structs and interfaces to reduce ambiguity 2025-09-22 22:02:37 +08:00
MaysWind d4fee27a3d fix the result of detecting whether a transaction has been modified is wrong when no default account is set 2025-09-22 00:59:10 +08:00
MaysWind 245fdd78e4 clear all transactions of specified account (#228) 2025-09-22 00:26:59 +08:00
MaysWind cbe784172e fix cannot connect PostgreSQL via Unix socket (#253) 2025-09-21 22:00:22 +08:00
MaysWind bf21e45cba update description of query_transactions mcp tool json scheme 2025-09-21 21:00:14 +08:00
MaysWind 359c430a39 add new translation contributor 2025-09-21 20:53:19 +08:00
MaysWind 669a217180 improve the style of long tips and drag-and-drop image 2025-09-21 20:48:50 +08:00
MaysWind e9507241ed update transaction 2025-09-21 20:34:17 +08:00
brieucdlf f2536749f6 feat(locale): add French translation 2025-09-21 19:05:52 +08:00
MaysWind 118558d25b modify the structure of the large language model options in the configuration file 2025-09-21 17:49:49 +08:00
MaysWind d9cd270ff4 don't save draft when creating transactions through AI image recognition in mobile version 2025-09-21 15:51:41 +08:00
MaysWind 9dee449f10 fix the incorrect mouse pointer style during image recognition 2025-09-21 15:31:53 +08:00
MaysWind ae19ca4383 improved the experience when no transaction information is detected 2025-09-21 15:28:26 +08:00
MaysWind 32fed8d6fb fix the application could not start if no LLM provider was configured 2025-09-21 15:16:34 +08:00
MaysWind ec325c9e6b fix AI image recognition limit could not be set for users 2025-09-21 15:15:50 +08:00
MaysWind 5fbb29abd3 modify style 2025-09-21 12:29:04 +08:00
MaysWind f06c6523a2 add capture=camera to the accept attribute in file input when selecting transaction pictures 2025-09-21 12:00:19 +08:00
MaysWind 02514fc457 use system control to take photo or select an image 2025-09-21 12:00:11 +08:00
MaysWind 5d88287ae2 create transactions from AI receipt image recognition results 2025-09-21 04:00:57 +08:00
MaysWind 00f1d0418f show execution error in preview area 2025-09-20 22:54:10 +08:00
MaysWind 18b270debb support for disabling the app’s built-in swipe-back navigation (#249) 2025-09-20 22:02:18 +08:00
MaysWind d947164eb6 add unit test 2025-09-20 21:34:13 +08:00
MaysWind 1a1bb6077c hidden transaction tags are not allowed when importing transactions or using the add_transaction mcp tool 2025-09-20 21:22:44 +08:00
MaysWind 05b5cab12b fix the add_transaction mcp tool set wrong categories from the wrong category type 2025-09-20 21:19:41 +08:00
MaysWind a82fdd4946 add unit tests 2025-09-20 21:08:42 +08:00
MaysWind 4def7ed60c the query_transactions_tool_handler mcp tool supports filtering multiple categories or accounts with the same name, and filtering sub-accounts / secondary categories by their parent account / category name 2025-09-20 21:08:34 +08:00
MaysWind d50ce0140f code refactor 2025-09-20 02:08:39 +08:00
MaysWind 51678aee04 don't return hidden tags for query_all_transaction_tags mcp tool 2025-09-20 00:42:42 +08:00
MaysWind 019689087d code refactor 2025-09-19 23:40:37 +08:00
MaysWind 0c1d77f7ae modify text 2025-09-19 21:35:48 +08:00
MaysWind 8de51e6e71 show the selected count even when the number of items is less than 10 2025-09-19 00:27:07 +08:00
MaysWind dc993da218 import investment transactions from alipay statement file (#246) 2025-09-19 00:18:19 +08:00
MaysWind 983f7fec0f fix can not replace empty transaction categories when using batch rules 2025-09-18 22:51:00 +08:00
MaysWind ce74c4817b fix transaction type was not checked when replacing transaction categories using batch rules (#248) 2025-09-18 22:42:30 +08:00
MaysWind bc363438f1 move the request_id_header option to the server section and the enable_two_factor option to the user section 2025-09-17 21:16:49 +08:00
MaysWind 979b16d520 display time numbers with fixed width style in the mobile version time picker 2025-09-17 21:07:22 +08:00
MaysWind 9686eb020f fix cannot import OFX file which has only one but very long line (#243) 2025-09-15 22:51:16 +08:00
MaysWind 88dea9acaa use the current browser's time zone as the sample 2025-09-15 00:38:39 +08:00
MaysWind c75fdfea1c support custom script to process delimiter-separated values (data) file / data 2025-09-15 00:21:14 +08:00
MaysWind 538d2b8205 the add transaction page of mobile version supports the destinationAccountId and comment parameters 2025-09-14 17:41:21 +08:00
MaysWind 30d36a3b07 add noUncheckedIndexedAccess compiler options 2025-09-14 17:29:42 +08:00
MaysWind 95bcd8e4c8 upgrade third party dependencies 2025-09-14 17:29:23 +08:00
MaysWind 1a8ce7d58d use for-of statements to replace for and for-in 2025-09-14 17:18:47 +08:00
MaysWind 4700446ca0 use for-of statements to replace for and for-in 2025-09-14 01:43:04 +08:00
MaysWind 67bc81d3e2 fix user custom exchange rates update page /dialog could not be opened 2025-09-14 01:17:46 +08:00
MaysWind 878a3a018e modify style 2025-09-14 01:11:02 +08:00
MaysWind e463c2dc95 batch adding transaction tags in import transaction tool 2025-09-13 23:05:26 +08:00
MaysWind 422cf49517 batch removing specified tag or batch removing invalid tag in import transaction tool 2025-09-13 23:00:39 +08:00
MaysWind 77d2426c14 code refactor 2025-09-13 21:17:55 +08:00
MaysWind 1c4dc55bb6 modify style 2025-09-13 15:37:25 +08:00
MaysWind ba72f421dc code refactor 2025-09-13 15:34:20 +08:00
MaysWind 36d1e01008 fix the display format of the fiscal year start date not updated after changing the number system on user profile page 2025-09-13 02:19:57 +08:00
MaysWind e52c7037c7 import transactions from JD.com finance statement file (#240) 2025-09-13 01:53:41 +08:00
MaysWind f5235ba08e modify text 2025-09-13 01:53:41 +08:00
xiaolvdou adc4899ea6 fix MCP protocol version requirement 2025-09-11 10:40:34 +08:00
MaysWind 34c5a1750e use for-of statements to replace for and for-in 2025-09-09 23:48:42 +08:00
MaysWind c75a902d84 fix could not select category name via checkbox in the batch create category dialog 2025-09-09 23:22:58 +08:00
MaysWind 7e2e1a4ad3 use truncation instead of rounding down or rounding to the nearest value when numerical calculations exceed precision limits 2025-09-09 20:46:51 +08:00
MaysWind d4603a1892 code refactor 2025-09-09 00:01:15 +08:00
MaysWind 642e51bc0c use and display the Gregorian calendar when calculating months, quarters, years, and fiscal years 2025-09-08 00:32:30 +08:00
MaysWind 5591abdb3b upgrade third party dependencies 2025-09-07 23:14:59 +08:00
MaysWind ce9378c43f support filtering accounts and transaction categories for overview in home page (#209) 2025-09-07 13:57:07 +08:00
MaysWind 3ae72623ad code refactor 2025-09-07 00:15:35 +08:00
MaysWind affc02655b remove redundant spaces 2025-09-06 23:34:35 +08:00
MaysWind a469d66358 calendar display type supports Gregorian with Persian, date display type supports Persian calendar 2025-09-06 23:34:29 +08:00
MaysWind 757f9e5b02 fix the toast text size and the text input size in dialogs on the mobile version did not adjust according to the font size settings 2025-09-06 01:18:05 +08:00
MaysWind 8368b02be8 calendar display type supports Gregorian with Chinese 2025-09-06 01:06:31 +08:00
MaysWind e15a5617e6 support dates with YYYYMMDD format when importing delimiter-separated values file / data (#219) 2025-09-04 22:04:14 +08:00
MaysWind f604b2c766 upgrade golang to 1.25.1, node.js to 24.7.0 2025-09-04 21:57:19 +08:00
MaysWind d6dc9f8170 fix the checkbox in the scheduled transaction frequency dropdown list couldn’t be used to select options (#225) 2025-09-03 19:50:00 +08:00
MaysWind a71be1bf05 fix cannot delete accounts / transaction categories and tags when using postgres db (#218) 2025-09-01 22:44:34 +08:00
MaysWind bcf11631d6 use integers to calculate formulas for beancount amount formula 2025-09-01 01:16:58 +08:00
MaysWind 989183c8be use integers to calculate formulas for evaluator (#214) 2025-09-01 00:32:14 +08:00
MaysWind 8bd0fd88af bump version to 1.1.0 2025-08-31 13:25:47 +08:00
MaysWind 20e2444307 modify color and background image in desktop version 2025-08-31 00:20:17 +08:00
MaysWind 8154bd712b check if the accounts, categories, and tags used exist when creating a transaction template 2025-08-30 01:22:40 +08:00
MaysWind 4d0e376568 code refactor 2025-08-30 00:40:33 +08:00
MaysWind 32cf41a7a0 modify style 2025-08-30 00:07:43 +08:00
MaysWind e85a4701ed fix cannot selecting time when the number system was not Arabic numerals 2025-08-29 00:53:23 +08:00
MaysWind b79ffafaee code refactor 2025-08-29 00:43:34 +08:00
MaysWind 8f6adaa417 fix some numerals were not displayed according to the numerical system 2025-08-29 00:43:11 +08:00
MaysWind 0e634d83f4 update README.md 2025-08-28 23:59:18 +08:00
MaysWind af8cbe0b15 fix the ellipsis was not displayed when the text was too long 2025-08-28 22:43:25 +08:00
MaysWind 411130db4e support calendar display type (Gregorian and Buddhist) 2025-08-28 00:31:59 +08:00
MaysWind c099443783 support date display type (Gregorian and Buddhist) 2025-08-27 00:58:22 +08:00
MaysWind 23ffdbb163 replacing third-party datetime formatter with internal formatter 2025-08-25 23:33:49 +08:00
MaysWind 0b48502a10 modify the style of the custom time range 2025-08-25 22:21:57 +08:00
MaysWind 25681f622d add explicit type for string-based datetimes, replacing third-party datetime type with internal DateTime type 2025-08-25 00:39:36 +08:00
MaysWind f196ce969b code refactor 2025-08-24 23:07:18 +08:00
MaysWind 0408c470fc fix the select dropdown menu was positioned incorrectly after filter box being focused 2025-08-24 23:06:58 +08:00
MaysWind 01aeb945ff check whether transaction template uses specified accounts / categories / tags when deleting them 2025-08-24 01:29:26 +08:00
MaysWind 601a1f83c6 fix the date range may be incorrect when switching between fiscal years 2025-08-24 00:23:49 +08:00
MaysWind 2a470742e0 change order 2025-08-24 00:08:34 +08:00
MaysWind 8ba1e1997f show the time range below the fiscal year option 2025-08-24 00:04:36 +08:00
MaysWind 27ae401a7f code refactor 2025-08-23 23:21:02 +08:00
MaysWind 81727d3b1e show import file type by categories 2025-08-23 02:25:24 +08:00
MaysWind 06a0501633 append error message to log 2025-08-22 21:48:35 +08:00
MaysWind 781c2d9044 retry up to 3 times when update user cloud settings 2025-08-22 00:08:08 +08:00
MaysWind 15e4ad00ee update description 2025-08-21 22:20:56 +08:00
MaysWind 8064a00252 fix confirm dialog color not taking effect 2025-08-21 00:03:25 +08:00
MaysWind f2d0fe407b support deleting all transactions (#202) 2025-08-21 00:01:25 +08:00
MaysWind 9589657fd5 update description 2025-08-20 22:55:16 +08:00
MaysWind 790837076f code refactor 2025-08-20 22:39:49 +08:00
MaysWind 6d923027a0 update README.md 2025-08-20 22:19:11 +08:00
MaysWind 13d5759e84 mobile version supports rtl 2025-08-20 01:13:39 +08:00
MaysWind efe39c7390 fix circular dependency problem 2025-08-18 01:30:58 +08:00
MaysWind c00770201b desktop version supports rtl 2025-08-18 00:45:26 +08:00
MaysWind 4eff3a337f fix repeated execution of multilingual processing 2025-08-17 02:09:09 +08:00
MaysWind 451385011e fix the default date was incorrect when updating the closing balance in the reconciliation statement dialog which the date range is all 2025-08-17 02:04:28 +08:00
MaysWind cd4d230d29 support changing numeral system 2025-08-17 01:55:19 +08:00
MaysWind ab6d4ee6fc code refactor 2025-08-16 01:07:27 +08:00
MaysWind 274aa6a17c digit grouping type supports Indian Number Grouping 2025-08-16 00:20:12 +08:00
MaysWind 2f8d4ad5e4 add new translation contributor 2025-08-13 21:57:04 +08:00
MaysWind fe59d3b280 update transaction 2025-08-13 09:52:32 +08:00
automagics e2c99c4f04 Add Dutch translation 2025-08-13 09:24:27 +08:00
MaysWind 127393b64a display the "Outstanding Balance" label instead of "Balance" for liability account category in desktop account list page 2025-08-10 17:10:59 +08:00
MaysWind f3d240442b modify balance modification transaction 2025-08-10 17:00:08 +08:00
MaysWind 55bf8b9e30 support showing transaction detail dialog / page 2025-08-10 16:59:08 +08:00
MaysWind eadcf7768f fix the total amount of all sub-accounts under the parent account was not counted after filtering any sub-accounts (#192) 2025-08-10 11:26:27 +08:00
MaysWind 876bf8cc31 update dark theme style of desktop version 2025-08-10 01:38:08 +08:00
MaysWind 6b5aac0111 fix the filter box could not be input 2025-08-09 02:13:06 +08:00
MaysWind dc4a4e1463 import latest wechat pay billing file format 2025-08-08 20:37:50 +08:00
MaysWind 0677ed07db code refactor 2025-08-08 20:19:02 +08:00
MaysWind ecf6fbd187 support setting whether the data table in csv / xls / xlsx files contains a header row 2025-08-08 20:15:11 +08:00
MaysWind 351cebe169 add sub basic data table 2025-08-08 20:05:39 +08:00
MaysWind 0f94a90882 add unit tests 2025-08-08 20:05:26 +08:00
MaysWind 04996d784f show the reason why the category / account select is disabled in desktop version 2025-08-06 00:05:35 +08:00
MaysWind aafcfeda84 upgrade node.js to 22.18.0 2025-08-05 23:37:22 +08:00
MaysWind 7283b724b1 add candlestick chart for account balance trends 2025-08-05 23:29:49 +08:00
MaysWind 0d55912f6c modify UI interaction for mode switching 2025-08-05 01:01:52 +08:00
MaysWind 60108e26c7 fix incorrect fiscal year closing balance for partial-year queries 2025-08-05 01:01:33 +08:00
MaysWind be129cd3c6 modify function name 2025-08-04 23:33:13 +08:00
MaysWind f210bfa9f4 use the first visible account as default if the default account is hidden when creating new transaction 2025-08-04 23:14:46 +08:00
MaysWind 263113a67f upgrade third party dependencies 2025-08-04 22:44:41 +08:00
MaysWind 3b29303237 show the reason why the transaction, account, and category cannot be saved on the save button in desktop version 2025-08-04 21:43:19 +08:00
MaysWind 6e5f857e97 show account outstanding balance for liability account in account balance trends chart 2025-08-04 21:04:41 +08:00
MaysWind 791c0ea26e modify style 2025-08-04 21:03:37 +08:00
MaysWind 84523d8b8a code refactor 2025-08-04 20:54:10 +08:00
MaysWind d35e127b9e code refactor 2025-08-04 01:25:55 +08:00
MaysWind ebe00d3271 code refactor 2025-08-04 01:22:54 +08:00
MaysWind 14b4e40039 reconciliation statement page / dialog supports account balance trends chart (#184) 2025-08-04 01:22:36 +08:00
MaysWind 15d1d269ae modify file name 2025-08-03 21:42:30 +08:00
MaysWind e90b76c80e code refactor 2025-08-03 14:27:34 +08:00
MaysWind e28e27080a fix the bug the default account shows "unspecified" in desktop version after the default account is set to hidden 2025-08-03 01:16:51 +08:00
MaysWind 2268496dcb translate text in vuetify controls 2025-08-03 00:58:49 +08:00
MaysWind 3781327c58 hide hidden sub-account 2025-08-02 23:53:11 +08:00
MaysWind 51c33d7e83 upgrade golang to 1.24.5, node.js to 22.17.0, alpine base image to 3.22.1 2025-08-02 10:45:00 +08:00
MaysWind 975a56e7d9 don't initialize avatar / transaction storage when they are not enabled 2025-08-02 10:16:00 +08:00
MaysWind 29a87dcfaf object storage supports webdav 2025-08-02 01:26:29 +08:00
MaysWind cad53d0bfc use the request context 2025-08-02 00:10:12 +08:00
MaysWind 56a3905df1 exclude deleted account 2025-08-01 23:21:37 +08:00
MaysWind 428a1f2156 show "data is up to date" when the data is not updated in reconciliation statement page / dialog 2025-08-01 23:13:03 +08:00
MaysWind b5233399e6 code refactor 2025-08-01 23:03:59 +08:00
MaysWind f8878c5405 disable transaction draft when update closing balance 2025-07-31 23:41:52 +08:00
MaysWind 8dcaa457f9 set the default date based on the current date time filter range when adding a new transaction in the transaction list page 2025-07-31 22:53:37 +08:00
MaysWind b24ebdb83e redesign time picker in date time select for desktop device 2025-07-31 22:19:12 +08:00
MaysWind d41a2141a7 modify style 2025-07-31 14:35:52 +08:00
MaysWind 09a1dd0358 update closing balance in reconciliation statement page 2025-07-30 23:07:04 +08:00
MaysWind 531c4a44d5 code refactor 2025-07-30 22:53:43 +08:00
MaysWind ceecff8c24 add refresh button in reconciliation statement dialog 2025-07-30 22:27:58 +08:00
MaysWind f32cc4ab04 fix the filter could not be saved after the filtered accounts, categories and transaction tags were deleted (#185) 2025-07-30 21:12:15 +08:00
MaysWind 8fa46281e0 update closing balance in reconciliation statement page / dialog 2025-07-30 00:56:55 +08:00
MaysWind f7bc4b3ab6 modify style 2025-07-30 00:56:34 +08:00
MaysWind ad4f5bd88d support duplicating / modifying / deleting transaction in reconciliation statement page 2025-07-29 00:58:15 +08:00
MaysWind e4cb66718d reload reconciliation statement after adding new transaction 2025-07-29 00:16:37 +08:00
MaysWind 175b272fa0 disable the view button of the balance modification transaction 2025-07-29 00:03:45 +08:00
MaysWind ca0fb9446b total inflows and outflows in reconciliation statement includes the amount of the balance modification transaction 2025-07-29 00:01:38 +08:00
MaysWind 6eb749dca2 move file 2025-07-28 23:20:45 +08:00
MaysWind 880b614636 clear the data of the last unfinished calculation when opening the number pad sheet 2025-07-28 23:17:39 +08:00
MaysWind d146a99c65 modify style 2025-07-28 22:49:15 +08:00
MaysWind fd99c784b3 modify style 2025-07-28 22:00:26 +08:00
MaysWind 22f9c5243a add reconciliation statement page for mobile version 2025-07-28 00:37:51 +08:00
MaysWind 67f5aaa5ee update README.md 2025-07-27 23:51:31 +08:00
MaysWind 713b621169 update category display name for balance modification transaction 2025-07-27 21:35:16 +08:00
MaysWind 80df5f95aa fix typo 2025-07-27 17:07:04 +08:00
MaysWind 1e492d8724 code refactor 2025-07-27 16:59:31 +08:00
MaysWind 602f15fe2e export reconciliation statements 2025-07-26 00:58:38 +08:00
MaysWind 3335533a18 remove token via cli 2025-07-25 00:01:05 +08:00
MaysWind d385358aa3 code refactor 2025-07-24 23:58:24 +08:00
MaysWind d6ee8a416f code refactor 2025-07-24 23:57:49 +08:00
MaysWind c5aa37037f the number of digits shown for hours, minutes, and seconds in the time picker depends on the user's language settings 2025-07-23 01:06:34 +08:00
MaysWind 6050f5deab add / view transaction in reconciliation statement dialog 2025-07-23 00:47:22 +08:00
MaysWind 5d07d1a70d modify style 2025-07-23 00:09:15 +08:00
MaysWind bae330c6f3 modify text 2025-07-23 00:02:34 +08:00
MaysWind ea17994c6c show opening / closing balance in reconciliation statement dialog 2025-07-22 01:04:29 +08:00
MaysWind c3d29ee2f8 modify text 2025-07-21 00:53:39 +08:00
MaysWind 515b9af61a add reconciliation statement in desktop version 2025-07-21 00:40:02 +08:00
MaysWind 4ba3893b83 remove unused code 2025-07-21 00:39:54 +08:00
MaysWind bcb6c4f419 time selector in mobile version supports loop selection 2025-07-21 00:39:32 +08:00
MaysWind 53f101fb60 fix the bug that amount format could not be automatically detected when importing dsv file 2025-07-20 13:09:26 +08:00
MaysWind 8da4f65048 load / save rules for batch replacing transaction categories / accounts / tags when import transactions 2025-07-20 01:51:20 +08:00
MaysWind 428bcba56e code refactor 2025-07-19 23:27:32 +08:00
MaysWind 68e896d8eb use the original transaction type of Firefly III as the imported transaction type 2025-07-15 23:13:49 +08:00
MaysWind eef62722a4 update third party dependency copyright 2025-07-13 23:59:13 +08:00
MaysWind e3dcb2ce0c fix wrong link 2025-07-13 23:17:24 +08:00
MaysWind 0cf89562cd use a unified commit hash length when building in different platform 2025-07-13 21:51:14 +08:00
MaysWind 8b06731cdb update go.mod 2025-07-13 17:19:34 +08:00
MaysWind 36abd1acec bump version to 1.0.0 2025-07-13 17:19:27 +08:00
419 changed files with 75088 additions and 11708 deletions
+70
View File
@@ -0,0 +1,70 @@
name: Bug Report
description: Report a bug in ezBookkeeping
labels: bug
body:
- type: checkboxes
id: checkboxes
attributes:
label: Before You Submit
description: Please check whether the following items have been completed.
options:
- label: I've already checked this bug hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
required: true
- type: textarea
id: description
attributes:
label: Description
description: Please provide a brief description of this bug.
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: Steps to reproduce
description: Please describe the steps to reproduce this bug.
placeholder: |
1.
2.
3.
validations:
required: true
- type: input
id: ezbookkeeping-version
attributes:
label: ezBookkeeping Version
description: ezBookkeeping version and commit hash of your instance, e.g. "v1.0.0 (20e2444)"
validations:
required: true
- type: input
id: server-os
attributes:
label: Server Operating System
description: The operating system information you are using to deploy ezBookkeeping, e.g "Debian GNU/Linux 11 amd64"
- type: input
id: server-database
attributes:
label: Database
description: The database system you are using, e.g. "MariaDB v11.7.2"
- type: dropdown
id: reproduce-on-demo-site
attributes:
label: Can you reproduce this bug on the ezBookkeeping demo site?
description: |
ezBookkeeping demo site: https://ezbookkeeping-demo.mayswind.net/
options:
- "No"
- "Yes"
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional information
description: If you can, provide any related screenshots or logs here.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,26 @@
name: Feature Request
description: Request a feature or enhancement for ezBookkeeping
labels: enhancement
body:
- type: checkboxes
id: checkboxes
attributes:
label: Before You Submit
description: Please check whether the following items have been completed.
options:
- label: I've already checked this request hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
required: true
- type: textarea
id: description
attributes:
label: Feature Description
description: Please describe your feature request.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional information
description: If you can, provide any other context or screenshots about this feature request here.
@@ -1,4 +1,4 @@
name: Docker Snapshot name: Build for Non-Main Branches
on: on:
push: push:
@@ -6,7 +6,7 @@ on:
- main - main
jobs: jobs:
build: build-linux-docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- -
+224
View File
@@ -0,0 +1,224 @@
name: Build Release
on:
push:
tags:
- "v*.*.*"
jobs:
build-linux-docker:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=semver,pattern={{version}}
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: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
RELEASE_BUILD=1
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
upload-linux-artifact:
needs: build-linux-docker
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: linux/amd64
arch_alias: linux-amd64
- arch: linux/arm64/v8
arch_alias: linux-arm64
- arch: linux/arm/v7
arch_alias: linux-armv7
- arch: linux/arm/v6
arch_alias: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Pull and save packaged files for ${{ matrix.arch }}
run: |
VERSION=${{ needs.build-linux-docker.outputs.image-tag }}
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${VERSION}
docker pull --platform ${{ matrix.arch }} ${IMAGE}
cid=$(docker create "${IMAGE}")
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
docker rm ${cid}
cd ezbookkeeping
tar -czf ../ezbookkeeping-v${VERSION}-${{ matrix.arch_alias }}.tar.gz *
cd ..
rm -rf ezbookkeeping
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}
path: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}.tar.gz
if-no-files-found: error
build-and-upload-windows-package:
needs: upload-linux-artifact
runs-on: windows-latest
env:
GO_VERSION: "1.25.1"
MINGW_VERSION: "14.2.0"
MINGW_REVISION: "v12-rev2"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
path: artifacts
- name: Extract frontend files from linux-amd64 package
run: |
New-Item -ItemType Directory -Path package
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-${{ github.ref_name }}-linux-amd64.tar.gz) -C package
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Install MinGW
run: |
$mingwVersion = "${{ env.MINGW_VERSION }}"
$mingwRevision = "${{ env.MINGW_REVISION }}"
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
$archive = "C:\mingw.7z"
$mingwDir = "C:\mingw64"
Write-Host "Downloading MinGW from ${url}"
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
Remove-Item -Recurse -Force ${mingwDir}
New-Item -ItemType Directory -Path ${mingwDir}
Write-Host "Extracting MinGW to ${mingwDir}"
7z x ${archive} -oC:\
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Build backend for windows-x64
env:
RELEASE_BUILD: "1"
BUILD_PIPELINE: "1"
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
run: |
.\build.ps1 backend
- name: Package Windows build
run: |
New-Item -ItemType Directory -Path "ezbookkeeping"
New-Item -ItemType Directory -Path "ezbookkeeping\data"
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
New-Item -ItemType Directory -Path "ezbookkeeping\log"
Copy-Item ezbookkeeping.exe -Destination ezbookkeeping\
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
Copy-Item .\LICENSE -Destination ezbookkeeping\
Push-Location ezbookkeeping
7z a -r -tzip -mx9 ..\ezbookkeeping-${{ github.ref_name }}-windows-x64.zip *
Pop-Location
Remove-Item -Recurse -Force ezbookkeeping
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
path: ezbookkeeping-${{ github.ref_name }}-windows-x64.zip
if-no-files-found: error
publish-release:
runs-on: ubuntu-latest
needs:
- upload-linux-artifact
- build-and-upload-windows-package
steps:
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
path: ./release-files
- name: Download linux-arm64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-arm64
path: ./release-files
- name: Download linux-armv6 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-armv6
path: ./release-files
- name: Download linux-armv7 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-linux-armv7
path: ./release-files
- name: Download windows-x64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
path: ./release-files
- name: Publish Release ${{ github.ref_name }}
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
files: ./release-files/*
draft: true
+178
View File
@@ -0,0 +1,178 @@
name: Build Snapshot
on:
push:
branches:
- main
jobs:
build-linux-docker:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}.${{ github.run_id }}
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-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: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
upload-linux-artifact:
needs: build-linux-docker
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: linux/amd64
arch_alias: linux-amd64
- arch: linux/arm64/v8
arch_alias: linux-arm64
- arch: linux/arm/v7
arch_alias: linux-armv7
- arch: linux/arm/v6
arch_alias: linux-armv6
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Pull and save packaged files for ${{ matrix.arch }}
run: |
TAG=${{ needs.build-linux-docker.outputs.image-tag }}
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${TAG}
docker pull --platform ${{ matrix.arch }} ${IMAGE}
cid=$(docker create "${IMAGE}")
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
docker rm ${cid}
cd ezbookkeeping
tar -czf ../ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz *
cd ..
rm -rf ezbookkeeping
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}
path: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz
if-no-files-found: error
build-and-upload-windows-package:
needs: upload-linux-artifact
runs-on: windows-latest
env:
GO_VERSION: "1.25.1"
MINGW_VERSION: "14.2.0"
MINGW_REVISION: "v12-rev2"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download linux-amd64 packaged files
uses: actions/download-artifact@v4
with:
name: ezbookkeeping-dev-${{ github.run_id }}-linux-amd64
path: artifacts
- name: Extract frontend files from linux-amd64 package
run: |
New-Item -ItemType Directory -Path package
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-dev-${{ github.run_id }}-linux-amd64.tar.gz) -C package
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Install MinGW
run: |
$mingwVersion = "${{ env.MINGW_VERSION }}"
$mingwRevision = "${{ env.MINGW_REVISION }}"
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
$archive = "C:\mingw.7z"
$mingwDir = "C:\mingw64"
Write-Host "Downloading MinGW from ${url}"
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
Remove-Item -Recurse -Force ${mingwDir}
New-Item -ItemType Directory -Path ${mingwDir}
Write-Host "Extracting MinGW to ${mingwDir}"
7z x ${archive} -oC:\
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Build backend for windows-x64
env:
BUILD_PIPELINE: "1"
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
run: |
.\build.ps1 backend
- name: Package Windows build
run: |
New-Item -ItemType Directory -Path "ezbookkeeping"
New-Item -ItemType Directory -Path "ezbookkeeping\data"
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
New-Item -ItemType Directory -Path "ezbookkeeping\log"
Copy-Item ezbookkeeping.exe -Destination ezbookkeeping\
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
Copy-Item .\LICENSE -Destination ezbookkeeping\
Push-Location ezbookkeeping
7z a -r -tzip -mx9 ..\ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip *
Pop-Location
Remove-Item -Recurse -Force ezbookkeeping
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: ezbookkeeping-dev-${{ github.run_id }}-windows-x64
path: ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip
if-no-files-found: error
-57
View File
@@ -1,57 +0,0 @@
name: Docker Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=semver,pattern={{version}}
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: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
RELEASE_BUILD=1
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-55
View File
@@ -1,55 +0,0 @@
name: Docker Snapshot
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
tags: |
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
type=raw,value=latest-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: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: |
linux/amd64
linux/arm64/v8
linux/arm/v7
linux/arm/v6
push: true
build-args: |
BUILD_PIPELINE=1
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3 -3
View File
@@ -1,5 +1,5 @@
# Build backend binary file # Build backend binary file
FROM golang:1.24.4-alpine3.22 AS be-builder FROM golang:1.25.1-alpine3.22 AS be-builder
ARG RELEASE_BUILD ARG RELEASE_BUILD
ARG BUILD_PIPELINE ARG BUILD_PIPELINE
ARG CHECK_3RD_API ARG CHECK_3RD_API
@@ -15,7 +15,7 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend RUN ./build.sh backend
# Build frontend files # Build frontend files
FROM --platform=$BUILDPLATFORM node:22.16.0-alpine3.22 AS fe-builder FROM --platform=$BUILDPLATFORM node:24.7.0-alpine3.22 AS fe-builder
ARG RELEASE_BUILD ARG RELEASE_BUILD
ARG BUILD_PIPELINE ARG BUILD_PIPELINE
ENV RELEASE_BUILD=$RELEASE_BUILD ENV RELEASE_BUILD=$RELEASE_BUILD
@@ -27,7 +27,7 @@ RUN apk add git
RUN ./build.sh frontend RUN ./build.sh frontend
# Package docker image # Package docker image
FROM alpine:3.22.0 FROM alpine:3.22.1
LABEL maintainer="MaysWind <i@mayswind.net>" LABEL maintainer="MaysWind <i@mayswind.net>"
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
RUN apk --no-cache add tzdata RUN apk --no-cache add tzdata
+21 -8
View File
@@ -1,14 +1,19 @@
# ezBookkeeping # ezBookkeeping
[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE) [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
[![Latest Build](https://img.shields.io/github/actions/workflow/status/mayswind/ezbookkeeping/docker-snapshot.yml?branch=main)](https://github.com/mayswind/ezbookkeeping/actions)
[![Go Report](https://goreportcard.com/badge/github.com/mayswind/ezbookkeeping)](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping) [![Go Report](https://goreportcard.com/badge/github.com/mayswind/ezbookkeeping)](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
[![Latest Docker Image Size](https://img.shields.io/docker/image-size/mayswind/ezbookkeeping.svg?style=flat)](https://hub.docker.com/r/mayswind/ezbookkeeping)
[![Latest Release](https://img.shields.io/github/release/mayswind/ezbookkeeping.svg?style=flat)](https://github.com/mayswind/ezbookkeeping/releases) [![Latest Release](https://img.shields.io/github/release/mayswind/ezbookkeeping.svg?style=flat)](https://github.com/mayswind/ezbookkeeping/releases)
[![Latest Build](https://img.shields.io/github/actions/workflow/status/mayswind/ezbookkeeping/build-snapshot.yml?branch=main)](https://github.com/mayswind/ezbookkeeping/actions)
[![Latest Docker Image Size](https://img.shields.io/docker/image-size/mayswind/ezbookkeeping.svg?style=flat)](https://hub.docker.com/r/mayswind/ezbookkeeping)
[![Docker Pulls](https://img.shields.io/docker/pulls/mayswind/ezbookkeeping)](https://hub.docker.com/r/mayswind/ezbookkeeping)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mayswind/ezbookkeeping)
[![Recommend By HelloGitHub](https://api.hellogithub.com/v1/widgets/recommend.svg?rid=ded5af09da574ec1811ddb154f1b2093&claim_uid=LT7EZxeBukCnh0K)](https://hellogithub.com/en/repository/mayswind/ezbookkeeping)
[![Trending](https://trendshift.io/api/badge/repositories/12917)](https://trendshift.io/repositories/12917)
## Introduction ## Introduction
ezBookkeeping is a lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features. Built with simplicity and portability in mind, it's easy to deploy, easy to use, and requires minimal system resources — perfect for microservers, NAS devices, and even Raspberry Pi. ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It's easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient and highly scalable, it can run smoothly on devices as small as a Raspberry Pi, or scale up to NAS, MicroServers, and even large cluster environments.
The app is fully cross-platform and device-friendly — you can use it seamlessly on **mobile, tablet, and desktop devices**. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app. ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net) Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
@@ -27,6 +32,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
- PWA support for native-like mobile experience - PWA support for native-like mobile experience
- Dark mode - Dark mode
- **AI-Powered Features** - **AI-Powered Features**
- Receipt image recognition
- Supports MCP (Model Context Protocol) for AI integration - Supports MCP (Model Context Protocol) for AI integration
- **Powerful Bookkeeping** - **Powerful Bookkeeping**
- Two-level accounts and categories - Two-level accounts and categories
@@ -91,6 +97,10 @@ All the files will be packaged in `ezbookkeeping.tar.gz`.
> .\build.bat package -o ezbookkeeping.zip > .\build.bat package -o ezbookkeeping.zip
or
PS > .\build.ps1 package -Output ezbookkeeping.zip
All the files will be packaged in `ezbookkeeping.zip`. All the files will be packaged in `ezbookkeeping.zip`.
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps: You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
@@ -100,7 +110,7 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
$ ./build.sh docker $ ./build.sh docker
## Contributing ## Contributing
We welcome contributions of all kinds! We welcome contributions of all kinds.
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues) Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
@@ -108,10 +118,10 @@ Want to contribute code? Feel free to fork and send a pull request.
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated. Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people whove already helped. Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
## Translating ## Translating
Help make ezBookkeeping accessible to users around the world! If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating). Help make ezBookkeeping accessible to users around the world. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
Currently available translations: Currently available translations:
@@ -120,16 +130,19 @@ Currently available translations:
| de | Deutsch | [@chrgm](https://github.com/chrgm) | | de | Deutsch | [@chrgm](https://github.com/chrgm) |
| en | English | / | | en | English | / |
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) | | es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) |
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
| it | Italiano | [@waron97](https://github.com/waron97) | | it | Italiano | [@waron97](https://github.com/waron97) |
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) | | ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
| nl | Nederlands | [@automagic](https://github.com/automagics) |
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) | | pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
| ru | Русский | [@artegoser](https://github.com/artegoser) | | ru | Русский | [@artegoser](https://github.com/artegoser) |
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) | | uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
| vi | Tiếng Việt | [@f97](https://github.com/f97) | | vi | Tiếng Việt | [@f97](https://github.com/f97) |
| zh-Hans | 中文 (简体) | / | | zh-Hans | 中文 (简体) | / |
| zh-Hant | 中文 (繁體) | / | | zh-Hant | 中文 (繁體) | / |
Don't see your language? Help us add it! Don't see your language? Help us add it.
## Documentation ## Documentation
1. [English](http://ezbookkeeping.mayswind.net) 1. [English](http://ezbookkeeping.mayswind.net)
+2 -2
View File
@@ -112,7 +112,7 @@ goto :pre_parse_args
set VERSION=%VERSION: =% set VERSION=%VERSION: =%
set VERSION=%VERSION:,=% set VERSION=%VERSION:,=%
set VERSION=%VERSION:"=% set VERSION=%VERSION:"=%
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x" for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
call :set_unixtime BUILD_UNIXTIME call :set_unixtime BUILD_UNIXTIME
call :set_date BUILD_DATE call :set_date BUILD_DATE
@@ -261,7 +261,7 @@ goto :pre_parse_args
goto :end goto :end
) )
call 7z a -r -tzip -mx9 ..\%package_file_name% package * call 7z a -r -tzip -mx9 ..\%package_file_name% *
cd .. cd ..
endlocal endlocal
+231
View File
@@ -0,0 +1,231 @@
param(
[string]$Type,
[switch]$NoLint,
[switch]$NoTest,
[string]$Output,
[switch]$Release,
[switch]$Help
)
$script:SkipTests = $env:SKIP_TESTS
$script:ReleaseType = "unknown"
$script:Version = ""
$script:CommitHash = ""
$script:BuildUnixTime = ""
$script:BuildDate = ""
function Write-Red($msg) {
Write-Host $msg -ForegroundColor Red
}
function Check-Dependency {
param([string[]]$commands)
foreach ($cmd in $commands) {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
Write-Red "Error: `"$cmd`" is required."
exit 127
}
}
}
function Show-Help {
Write-Host "ezBookkeeping build script for Windows PowerShell"
Write-Host ""
Write-Host "Usage:"
Write-Host " build.ps1 type [options]"
Write-Host ""
Write-Host "Types:"
Write-Host " backend Build backend binary file"
Write-Host " frontend Build frontend files"
Write-Host " package Build package archive"
Write-Host ""
Write-Host "Options:"
Write-Host " -Release Build release (The script will use environment variable `"RELEASE_BUILD`" to detect whether this is release building by default)"
Write-Host " -Output <filename> Package file name (for `"package`" type only)"
Write-Host " -NoLint Do not execute lint check before building"
Write-Host " -NoTest Do not execute unit testing before building (You can use environment variable `"SKIP_TESTS`" to skip specified tests)"
Write-Host " -Help Show help"
}
function Parse-Args {
if (-not $Type) {
Show-Help
exit 0
}
if ($Release -or $env:RELEASE_BUILD) {
$script:ReleaseType = "release"
} else {
$script:ReleaseType = "snapshot"
}
}
function Check-Type-Dependencies {
Check-Dependency "git"
switch ($Type.ToLower()) {
"backend" {
Check-Dependency "go","gcc"
}
"frontend" {
Check-Dependency "node","npm"
}
"package" {
Check-Dependency "go","gcc","node","npm","7z"
}
}
}
function Set-Build-Parameters {
$script:Version = (Get-Content package.json | ConvertFrom-Json).version
$script:CommitHash = git rev-parse --short=7 HEAD
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
$script:BuildDate = Get-Date -Format "yyyyMMdd"
}
function Build-Backend {
Write-Host "Pulling backend dependencies..."
go get .
if (-not $NoLint) {
Write-Host "Executing backend lint checking..."
go vet -v .\...
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass lint checking"
exit 1
}
}
if (-not $NoTest) {
Write-Host "Executing backend unit testing..."
go clean -cache
if (-not $SkipTests) {
go test .\... -v
} else {
Write-Host "(Skip unit test `"$SkipTests`")"
go test .\... -v -skip "$SkipTests"
}
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass unit testing"
exit 1
}
}
$backend_build_extra_arguments = "-X main.Version=$Version "
$backend_build_extra_arguments = "$backend_build_extra_arguments -X main.CommitHash=$CommitHash"
if (-not $Release) {
$backend_build_extra_arguments += " -X main.BuildUnixTime=$BuildUnixTime"
}
Write-Host "Building backend binary file ($ReleaseType)..."
$env:CGO_ENABLED = 1
go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping.exe ezbookkeeping.go
Remove-Item Env:\CGO_ENABLED -ErrorAction SilentlyContinue
}
function Build-Frontend {
Write-Host "Pulling frontend dependencies..."
npm install
if (-not $NoLint) {
Write-Host "Executing frontend lint checking..."
npm run lint
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass lint checking"
exit 1
}
}
if (-not $NoTest) {
Write-Host "Executing frontend unit testing..."
npm run test
if ($LASTEXITCODE -ne 0) {
Write-Red "Error: Failed to pass unit testing"
exit 1
}
}
Write-Host "Building frontend files ($ReleaseType)..."
if (-not $Release) {
$env:buildUnixTime = $BuildUnixTime
npm run build
Remove-Item Env:\buildUnixTime -ErrorAction SilentlyContinue
} else {
npm run build
}
}
function Build-Package {
$packageFileName = "ezbookkeeping-$Version"
if (-not $Release) {
$packageFileName = "$packageFileName-$BuildDate"
}
$packageFileName = "$packageFileName-windows.zip"
if ($Output) {
$packageFileName = $Output
}
Write-Host "Building package archive '$packageFileName' ($ReleaseType)..."
Build-Backend
Build-Frontend
Remove-Item package -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path "package"
New-Item -ItemType Directory -Path "package\data"
New-Item -ItemType Directory -Path "package\storage"
New-Item -ItemType Directory -Path "package\log"
Copy-Item ezbookkeeping.exe package\
Copy-Item dist package\public -Recurse
Copy-Item conf package\conf -Recurse
Copy-Item templates package\templates -Recurse
Copy-Item LICENSE package\
Push-Location package
7z a -r -tzip -mx9 "..\$packageFileName" *
Pop-Location
}
function Main {
if ($Help) {
Show-Help
exit 0
}
Parse-Args
Check-Type-Dependencies
Set-Build-Parameters
switch ($Type) {
"backend" {
Build-Backend
}
"frontend" {
Build-Frontend
}
"package" {
Build-Package
}
default {
Write-Red "Invalid type: $Type"
Show-Help
exit 2
}
}
}
Main
+1 -1
View File
@@ -117,7 +117,7 @@ check_type_dependencies() {
set_build_parameters() { set_build_parameters() {
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')" VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
COMMIT_HASH="$(git rev-parse --short HEAD)" COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
BUILD_UNIXTIME="$(date '+%s')" BUILD_UNIXTIME="$(date '+%s')"
} }
+20
View File
@@ -9,6 +9,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/datastore" "github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates" "github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"github.com/mayswind/ezbookkeeping/pkg/llm"
"github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mail" "github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/settings"
@@ -90,6 +91,15 @@ func initializeSystem(c *core.CliContext) (*settings.Config, error) {
return nil, err return nil, err
} }
err = llm.InitializeLargeLanguageModelProvider(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf(c, "[initializer.initializeSystem] initializes large language model provider failed, because %s", err.Error())
}
return nil, err
}
err = uuid.InitializeUuidGenerator(config) err = uuid.InitializeUuidGenerator(config)
if err != nil { if err != nil {
@@ -158,5 +168,15 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
clonedConfig.SecretKey = "****" clonedConfig.SecretKey = "****"
clonedConfig.AmapApplicationSecret = "****" clonedConfig.AmapApplicationSecret = "****"
if clonedConfig.WebDAVConfig != nil {
clonedConfig.WebDAVConfig.Password = "****"
}
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
}
return clonedConfig return clonedConfig
} }
+37 -1
View File
@@ -268,6 +268,19 @@ var UserData = &cli.Command{
}, },
}, },
}, },
{
Name: "user-session-revoke",
Usage: "Revoke the specified user session",
Action: bindAction(revokeUserToken),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Required: false,
Usage: "Specific token content",
},
},
},
{ {
Name: "user-session-clear", Name: "user-session-clear",
Usage: "Clear user all sessions", Usage: "Clear user all sessions",
@@ -732,6 +745,26 @@ func createNewUserToken(c *core.CliContext) error {
return nil return nil
} }
func revokeUserToken(c *core.CliContext) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
token := c.String("token")
err = clis.UserData.RevokeUserToken(c, token)
if err != nil {
log.CliErrorf(c, "[user_data.revokeUserToken] error occurs when revoking user token")
return err
}
log.CliInfof(c, "[user_data.revokeUserToken] the specified user token has been revoked successfully")
return nil
}
func clearUserTokens(c *core.CliContext) error { func clearUserTokens(c *core.CliContext) error {
_, err := initializeSystem(c) _, err := initializeSystem(c)
@@ -913,15 +946,18 @@ func printUserInfo(user *models.User) {
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency) fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek) fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart) fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart)
fmt.Printf("[CalendarDisplayType] %s (%d)\n", user.CalendarDisplayType, user.CalendarDisplayType)
fmt.Printf("[DateDisplayType] %s (%d)\n", user.DateDisplayType, user.DateDisplayType)
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat) fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat) fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat) fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat) fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat) fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat)
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
fmt.Printf("[NumeralSystem] %s (%d)\n", user.NumeralSystem, user.NumeralSystem)
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator) fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol) fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping) 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("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor) fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor) fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
+2 -2
View File
@@ -81,13 +81,13 @@ func sendTestMail(c *core.CliContext) error {
return err return err
} }
if !config.EnableSMTP || mail.Container.Current == nil { if !config.EnableSMTP {
return errs.ErrSMTPServerNotEnabled return errs.ErrSMTPServerNotEnabled
} }
toAddress := c.String("to") toAddress := c.String("to")
err = mail.Container.Current.SendMail(&mail.MailMessage{ err = mail.Container.SendMail(&mail.MailMessage{
To: toAddress, To: toAddress,
Subject: "ezBookkeeping test e-mail", Subject: "ezBookkeeping test e-mail",
Body: "This is a test e-mail", Body: "This is a test e-mail",
+15 -5
View File
@@ -79,7 +79,7 @@ func startWebServer(c *core.CliContext) error {
return err return err
} }
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.Current.GetCurrentServerUniqId(), requestid.Container.Current.GetCurrentInstanceUniqId()) serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.GetCurrentServerUniqId(), requestid.Container.GetCurrentInstanceUniqId())
uuidServerInfo := "" uuidServerInfo := ""
if config.UuidGeneratorType == settings.InternalUuidGeneratorType { if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId) uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
@@ -323,7 +323,9 @@ func startWebServer(c *core.CliContext) error {
// Data // Data
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler)) apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler)) apiV1Route.POST("/data/clear/all.json", bindApi(api.DataManagements.ClearAllDataHandler))
apiV1Route.POST("/data/clear/transactions.json", bindApi(api.DataManagements.ClearAllTransactionsHandler))
apiV1Route.POST("/data/clear/transactions/by_account.json", bindApi(api.DataManagements.ClearAllTransactionsByAccountHandler))
if config.EnableDataExport { if config.EnableDataExport {
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler)) apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
@@ -344,6 +346,7 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler)) apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler)) apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler)) apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
apiV1Route.GET("/transactions/reconciliation_statements.json", bindApi(api.Transactions.TransactionReconciliationStatementHandler))
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler)) apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler)) apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler)) apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
@@ -394,6 +397,13 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler)) apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler)) apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
// Large Language Models
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
if config.TransactionFromAIImageRecognition {
apiV1Route.POST("/llm/transactions/recognize_receipt_image.json", bindApi(api.LargeLanguageModels.RecognizeReceiptImageHandler))
}
}
// Exchange Rates // Exchange Rates
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler)) 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/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
@@ -521,7 +531,7 @@ func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.Han
if err != nil { if err != nil {
utils.PrintDataErrorResult(c, "text/javascript", err) utils.PrintDataErrorResult(c, "text/javascript", err)
} else { } else {
utils.PrintDataSuccessResult(c, "text/javascript", "", result) utils.PrintDataSuccessResult(c, "text/javascript; charset=utf-8", "", result)
} }
}) })
} }
@@ -534,7 +544,7 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
if err != nil { if err != nil {
utils.PrintDataErrorResult(c, "text/text", err) utils.PrintDataErrorResult(c, "text/text", err)
} else { } else {
utils.PrintDataSuccessResult(c, "text/csv", fileName, result) utils.PrintDataSuccessResult(c, "text/csv; charset=utf-8", fileName, result)
} }
} }
} }
@@ -547,7 +557,7 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
if err != nil { if err != nil {
utils.PrintDataErrorResult(c, "text/text", err) utils.PrintDataErrorResult(c, "text/text", err)
} else { } else {
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result) utils.PrintDataSuccessResult(c, "text/tab-separated-values; charset=utf-8", fileName, result)
} }
} }
} }
+89 -12
View File
@@ -37,6 +37,9 @@ enable_gzip = false
# Set to true to log each request and execution time # Set to true to log each request and execution time
log_request = true log_request = true
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
[mcp] [mcp]
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access # Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
enable_mcp = false enable_mcp = false
@@ -115,7 +118,7 @@ log_file_max_size = 104857600
log_file_max_days = 7 log_file_max_days = 7
[storage] [storage]
# Object storage type, supports "local_filesystem" and "minio" currently # Object storage type, supports "local_filesystem", "minio" and "webdav" currently
type = local_filesystem type = local_filesystem
# For "local_filesystem" storage only, the storage root path (relative or absolute path) # For "local_filesystem" storage only, the storage root path (relative or absolute path)
@@ -139,6 +142,82 @@ minio_bucket = ezbookkeeping
# For "minio" storage only, the root path to store files in minio # For "minio" storage only, the root path to store files in minio
minio_root_path = / minio_root_path = /
# For "webdav" storage only, the webdav url
webdav_url =
# For "webdav" storage only, the webdav username
webdav_username =
# For "webdav" storage only, the webdav password
webdav_password =
# For "webdav" storage only, the webdav root path to store files
webdav_root_path = /
# For "webdav" storage only, requesting webdav url timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting webdav url, default is 10000 (10 seconds)
webdav_request_timeout = 10000
# For "webdav" storage only, proxy for requesting webdav url, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
webdav_proxy = system
# For "webdav" storage only, set to true to skip tls verification when connect webdav
webdav_skip_tls_verify = false
[llm]
# Set to true to enable creating transactions from AI image recognition results, requires "llm_provider" and its related model id to be configured properly in "llm_image_recognition" section
transaction_from_ai_image_recognition = false
# Maximum allowed AI recognition picture file size (1 - 4294967295 bytes)
max_ai_recognition_picture_size = 10485760
[llm_image_recognition]
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "openrouter", "ollama", "google_ai"
llm_provider =
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
openai_api_key =
# For "openai" llm provider only, receipt image recognition model for creating transactions from images
openai_model_id =
# For "openai_compatible" llm provider only, OpenAI compatible API base url, e.g. "https://api.openai.com/v1/"
openai_compatible_base_url =
# For "openai_compatible" llm provider only, OpenAI compatible API secret key
openai_compatible_api_key =
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
openai_compatible_model_id =
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
openrouter_api_key =
# For "openrouter" llm provider only, receipt image recognition model for creating transactions from images
openrouter_model_id =
# For "ollama" llm provider only, Ollama server url, e.g. "http://127.0.0.1:11434/"
ollama_server_url =
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
ollama_model_id =
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
google_ai_api_key =
# For "google_ai" llm provider only, receipt image recognition model for creating transactions from images
google_ai_model_id =
# Requesting large language model api timeout (0 - 4294967295 milliseconds)
# Set to 0 to disable timeout for requesting large language model api, default is 60000 (60 seconds)
request_timeout = 60000
# Proxy for ezbookkeeping server requesting large language model api, 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 large language model api
skip_tls_verify = false
[uuid] [uuid]
# Uuid generator type, supports "internal" currently # Uuid generator type, supports "internal" currently
generator_type = internal generator_type = internal
@@ -168,9 +247,6 @@ enable_create_scheduled_transaction = true
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping # Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
secret_key = secret_key =
# Set to true to enable two-factor authorization
enable_two_factor = true
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days) # Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
token_expired_time = 2592000 token_expired_time = 2592000
@@ -193,8 +269,15 @@ 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 # 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 max_failures_per_user_per_minute = 5
# Add X-Request-Id header to response to track user request or error, default is true [auth]
request_id_header = true # Set to true to enable two-factor authorization
enable_two_factor = true
# 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
[user] [user]
# Set to true to allow users to register account by themselves # Set to true to allow users to register account by themselves
@@ -206,12 +289,6 @@ enable_email_verify = false
# Set to true to require email must be verified when login # Set to true to require email must be verified when login
enable_force_email_verify = false 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 # Set to true to allow users to upload transaction pictures
enable_transaction_picture = true enable_transaction_picture = true
+1 -1
View File
@@ -32,7 +32,7 @@ func main() {
cmd := &cli.Command{ cmd := &cli.Command{
Name: "ezBookkeeping", Name: "ezBookkeeping",
Usage: "A lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.", Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
Version: GetFullVersion(), Version: GetFullVersion(),
Commands: []*cli.Command{ Commands: []*cli.Command{
cmd.WebServer, cmd.WebServer,
+23 -23
View File
@@ -1,34 +1,35 @@
module github.com/mayswind/ezbookkeeping module github.com/mayswind/ezbookkeeping
go 1.24 go 1.25
require ( require (
github.com/boombuler/barcode v1.0.2 github.com/boombuler/barcode v1.1.0
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
github.com/gin-contrib/cache v1.4.0 github.com/gin-contrib/cache v1.4.1
github.com/gin-contrib/gzip v1.2.3 github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.2 github.com/go-co-op/gocron/v2 v2.16.5
github.com/go-playground/validator/v10 v10.26.0 github.com/go-playground/validator/v10 v10.27.0
github.com/go-sql-driver/mysql v1.9.2 github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/invopop/jsonschema v0.13.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.28 github.com/mattn/go-sqlite3 v1.14.32
github.com/minio/minio-go/v7 v7.0.92 github.com/minio/minio-go/v7 v7.0.95
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.3.3 github.com/urfave/cli/v3 v3.4.1
github.com/wk8/go-ordered-map/v2 v2.1.8 github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/xuri/excelize/v2 v2.9.0 github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.41.0
golang.org/x/net v0.40.0 golang.org/x/net v0.43.0
golang.org/x/text v0.25.0 golang.org/x/text v0.28.0
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.0
gopkg.in/mail.v2 v2.3.1 gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
xorm.io/xorm v1.3.9 xorm.io/xorm v1.3.10
) )
require ( require (
@@ -36,7 +37,7 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
github.com/buger/jsonparser v1.1.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
@@ -57,22 +58,21 @@ require (
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.9.2 // indirect github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/memcachier/mc/v3 v3.0.3 // indirect github.com/memcachier/mc/v3 v3.0.3 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect
@@ -85,13 +85,13 @@ require (
github.com/tiendc/go-deepcopy v1.6.0 // indirect github.com/tiendc/go-deepcopy v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.14 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/xuri/efp v0.0.1 // indirect github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect github.com/xuri/nfp v0.0.1 // indirect
golang.org/x/arch v0.17.0 // indirect golang.org/x/arch v0.18.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+42 -42
View File
@@ -4,16 +4,16 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0p
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 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/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-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 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/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.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= 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.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 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 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
@@ -42,30 +42,30 @@ github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cache v1.4.0 h1:d1FUqCE2+gJQKT0vJjr7jMn1htW9+cypk5oF7aoQcmE= github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
github.com/gin-contrib/cache v1.4.0/go.mod h1:6d0UAPedInkublPl/uJUB4bqwsEgJI1y5QGszhqnyxg= github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U= 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/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4= github.com/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI= github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 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-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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 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/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
@@ -87,8 +87,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -101,16 +101,16 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/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 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug= 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.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 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/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw= github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0= github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -125,8 +125,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -153,8 +153,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE= github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
@@ -164,10 +164,10 @@ github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 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/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/urfave/cli/v3 v3.4.1/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 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 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/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
@@ -178,23 +178,23 @@ github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmji
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 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-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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 h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
@@ -215,5 +215,5 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU= xorm.io/xorm v1.3.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o=
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw= xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ=
+2780 -1811
View File
File diff suppressed because it is too large Load Diff
+36 -29
View File
@@ -1,6 +1,6 @@
{ {
"name": "ezbookkeeping", "name": "ezbookkeeping",
"version": "0.10.0", "version": "1.1.0",
"private": true, "private": true,
"repository": { "repository": {
"type": "git", "type": "git",
@@ -21,63 +21,70 @@
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^11.0.2", "@vuepic/vue-datepicker": "^11.0.2",
"axios": "^1.9.0", "axios": "^1.11.0",
"cbor-js": "^0.1.0", "cbor-js": "^0.1.0",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dom7": "^4.0.6", "dom7": "^4.0.6",
"echarts": "^5.6.0", "echarts": "^5.5.1",
"framework7": "^8.3.4", "framework7": "^8.3.4",
"framework7-icons": "^5.0.5", "framework7-icons": "^5.0.5",
"framework7-vue": "^8.3.4", "framework7-vue": "^8.3.4",
"jalaali-js": "^1.2.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"line-awesome": "^1.3.0", "line-awesome": "^1.3.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.6.0", "moment-timezone": "^0.6.0",
"pinia": "^3.0.2", "pinia": "^3.0.3",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1", "skeleton-elements": "^4.0.1",
"swiper": "^10.2.0", "swiper": "^10.2.0",
"ua-parser-js": "^1.0.39", "ua-parser-js": "^1.0.39",
"vue": "^3.5.16", "vue": "^3.5.21",
"vue-echarts": "^7.0.3", "vue-echarts": "^7.0.3",
"vue-i18n": "^11.1.5", "vue-i18n": "^11.1.12",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue3-perfect-scrollbar": "^2.0.0", "vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"vuetify": "^3.8.7" "vuetify": "^3.9.7"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.7.0", "@jest/globals": "^30.1.2",
"@tsconfig/node22": "^22.0.2", "@tsconfig/node24": "^24.0.1",
"@types/cbor-js": "^0.1.1", "@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2", "@types/git-rev-sync": "^2.0.2",
"@types/jest": "^29.5.14", "@types/jalaali-js": "^1.2.0",
"@types/node": "^22.15.29", "@types/jest": "^30.0.0",
"@types/node": "^24.3.1",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.5.0", "@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.8.1",
"cross-env": "^7.0.3", "cross-env": "^10.0.0",
"eslint": "^9.28.0", "eslint": "^9.35.0",
"eslint-plugin-vue": "^10.1.0", "eslint-plugin-vue": "^10.4.0",
"git-rev-sync": "^3.0.2", "git-rev-sync": "^3.0.2",
"jest": "^29.7.0", "jest": "^30.1.3",
"postcss-preset-env": "^10.2.0", "postcss-preset-env": "^10.3.1",
"sass": "^1.89.1", "sass": "^1.92.1",
"ts-jest": "^29.3.4", "ts-jest": "^29.4.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"vite": "^6.3.5", "vite": "^7.1.4",
"vite-plugin-checker": "^0.9.3", "vite-plugin-checker": "^0.10.3",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.3",
"vite-plugin-vuetify": "^2.1.1", "vite-plugin-vuetify": "^2.1.2",
"vue-tsc": "^2.2.10" "vue-tsc": "^3.0.6"
}, },
"browserslist": [ "browserslist": [
"> 1%", "last 5 Chrome versions",
"last 2 versions", "last 5 Firefox versions",
"last 5 Safari versions",
"last 5 Edge versions",
"last 5 ChromeAndroid versions",
"last 5 iOS versions",
"not IE <= 11",
"not dead" "not dead"
] ]
} }
+10 -10
View File
@@ -23,7 +23,7 @@ type ApiUsingConfig struct {
// CurrentConfig returns the current config // CurrentConfig returns the current config
func (a *ApiUsingConfig) CurrentConfig() *settings.Config { func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
return a.container.Current return a.container.GetCurrentConfig()
} }
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model // GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
@@ -53,15 +53,15 @@ func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string
language = clientLanguage language = clientLanguage
} }
if !a.container.Current.AfterRegisterNotification.Enabled { if !a.CurrentConfig().AfterRegisterNotification.Enabled {
return "" return ""
} }
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists { if multiLanguageContent, exists := a.CurrentConfig().AfterRegisterNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent return multiLanguageContent
} }
return a.container.Current.AfterRegisterNotification.DefaultContent return a.CurrentConfig().AfterRegisterNotification.DefaultContent
} }
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in // GetAfterLoginNotificationContent returns the notification content displayed each time users log in
@@ -72,15 +72,15 @@ func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, c
language = clientLanguage language = clientLanguage
} }
if !a.container.Current.AfterLoginNotification.Enabled { if !a.CurrentConfig().AfterLoginNotification.Enabled {
return "" return ""
} }
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists { if multiLanguageContent, exists := a.CurrentConfig().AfterLoginNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent return multiLanguageContent
} }
return a.container.Current.AfterLoginNotification.DefaultContent return a.CurrentConfig().AfterLoginNotification.DefaultContent
} }
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app // GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
@@ -91,15 +91,15 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
language = clientLanguage language = clientLanguage
} }
if !a.container.Current.AfterOpenNotification.Enabled { if !a.CurrentConfig().AfterOpenNotification.Enabled {
return "" return ""
} }
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists { if multiLanguageContent, exists := a.CurrentConfig().AfterOpenNotification.MultiLanguageContent[language]; exists {
return multiLanguageContent return multiLanguageContent
} }
return a.container.Current.AfterOpenNotification.DefaultContent return a.CurrentConfig().AfterOpenNotification.DefaultContent
} }
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker // ApiUsingDuplicateChecker represents an api that need to use duplicate checker
+107 -11
View File
@@ -15,6 +15,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/utils"
) )
const pageCountForClearTransactions = 1000
const pageCountForDataExport = 1000 const pageCountForDataExport = 1000
// DataManagementsApi represents data management api // DataManagementsApi represents data management api
@@ -124,13 +125,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
return dataStatisticsResp, nil return dataStatisticsResp, nil
} }
// ClearDataHandler deletes all user data // ClearAllDataHandler deletes all user data
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) { func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearDataRequest var clearDataReq models.ClearDataRequest
err := c.ShouldBindJSON(&clearDataReq) err := c.ShouldBindJSON(&clearDataReq)
if err != nil { if err != nil {
log.Warnf(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error()) log.Warnf(c, "[data_managements.ClearAllDataHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
} }
@@ -139,7 +140,7 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
if err != nil { if err != nil {
if !errs.IsCustomError(err) { if !errs.IsCustomError(err) {
log.Warnf(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error()) log.Warnf(c, "[data_managements.ClearAllDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
} }
return nil, errs.ErrUserNotFound return nil, errs.ErrUserNotFound
@@ -156,39 +157,134 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
err = a.templates.DeleteAllTemplates(c, uid) err = a.templates.DeleteAllTemplates(c, uid)
if err != nil { if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction templates, because %s", err.Error()) log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction templates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
err = a.transactions.DeleteAllTransactions(c, uid) err = a.transactions.DeleteAllTransactions(c, uid, true)
if err != nil { if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error()) log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transactions, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
err = a.categories.DeleteAllCategories(c, uid) err = a.categories.DeleteAllCategories(c, uid)
if err != nil { if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error()) log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction categories, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
err = a.tags.DeleteAllTags(c, uid) err = a.tags.DeleteAllTags(c, uid)
if err != nil { if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error()) log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tags, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid) err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
if err != nil { if err != nil {
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all user custom exchange rates, because %s", err.Error()) log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid) log.Infof(c, "[data_managements.ClearAllDataHandler] user \"uid:%d\" has cleared all data", uid)
return true, nil
}
// ClearAllTransactionsHandler deletes all transactions
func (a *DataManagementsApi) ClearAllTransactionsHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearDataRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] 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.Warnf(c, "[data_managements.ClearAllTransactionsHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
err = a.transactions.DeleteAllTransactions(c, uid, false)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllTransactionsHandler] failed to delete all transactions, because %s", err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearAllTransactionsHandler] user \"uid:%d\" has cleared all transactions", uid)
return true, nil
}
// ClearAllTransactionsByAccountHandler deletes all transactions of specified account
func (a *DataManagementsApi) ClearAllTransactionsByAccountHandler(c *core.WebContext) (any, *errs.Error) {
var clearDataReq models.ClearAccountTransactionsRequest
err := c.ShouldBindJSON(&clearDataReq)
if err != nil {
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] 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.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
return nil, errs.ErrUserPasswordWrong
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
return nil, errs.ErrNotPermittedToPerformThisAction
}
account, err := a.accounts.GetAccountByAccountId(c, uid, clearDataReq.AccountId)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", uid, clearDataReq.AccountId, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if account.Hidden {
return nil, errs.ErrCannotDeleteTransactionInHiddenAccount
}
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
return nil, errs.ErrCannotDeleteTransactionInParentAccount
}
err = a.transactions.DeleteAllTransactionsOfAccount(c, uid, account.AccountId, pageCountForClearTransactions)
if err != nil {
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to delete all transactions in account \"id:%d\", because %s", account.AccountId, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
log.Infof(c, "[data_managements.ClearAllTransactionsByAccountHandler] user \"uid:%d\" has cleared all transactions in account \"id:%d\"", uid, account.AccountId)
return true, nil return true, nil
} }
+1 -7
View File
@@ -30,13 +30,7 @@ var (
// LatestExchangeRateHandler returns latest exchange rate data // LatestExchangeRateHandler returns latest exchange rate data
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) { func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
dataSource := exchangerates.Container.Current exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
if dataSource == nil {
return nil, errs.ErrInvalidExchangeRatesDataSource
}
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(c, c.GetCurrentUid(), a.container.Current)
if err != nil { if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
+374
View File
@@ -0,0 +1,374 @@
package api
import (
"bytes"
"encoding/json"
"io"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/llm"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
"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/templates"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// LargeLanguageModelsApi represents large language models api
type LargeLanguageModelsApi struct {
ApiUsingConfig
transactionCategories *services.TransactionCategoryService
transactionTags *services.TransactionTagService
accounts *services.AccountService
users *services.UserService
}
// Initialize a large language models api singleton instance
var (
LargeLanguageModels = &LargeLanguageModelsApi{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
transactionCategories: services.TransactionCategories,
transactionTags: services.TransactionTags,
accounts: services.Accounts,
users: services.Users,
}
)
// RecognizeReceiptImageHandler returns the recognized receipt image result
func (a *LargeLanguageModelsApi) RecognizeReceiptImageHandler(c *core.WebContext) (any, *errs.Error) {
if a.CurrentConfig().ReceiptImageRecognitionLLMConfig == nil || a.CurrentConfig().ReceiptImageRecognitionLLMConfig.LLMProvider == "" || !a.CurrentConfig().TransactionFromAIImageRecognition {
return nil, errs.ErrLargeLanguageModelProviderNotEnabled
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
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, "[large_language_models.RecognizeReceiptImageHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
}
return false, errs.ErrUserNotFound
}
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION) {
return false, errs.ErrNotPermittedToPerformThisAction
}
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
imageFiles := form.File["image"]
if len(imageFiles) < 1 {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] there is no image in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoAIRecognitionImage
}
if imageFiles[0].Size < 1 {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the size of image in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrAIRecognitionImageIsEmpty
}
if imageFiles[0].Size > int64(a.CurrentConfig().MaxAIRecognitionPictureFileSize) {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of image for user \"uid:%d\"", imageFiles[0].Size, a.CurrentConfig().MaxAIRecognitionPictureFileSize, uid)
return nil, errs.ErrExceedMaxAIRecognitionImageFileSize
}
fileExtension := utils.GetFileNameExtension(imageFiles[0].Filename)
contentType := utils.GetImageContentType(fileExtension)
if contentType == "" {
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the file extension \"%s\" of image in request is not supported for user \"uid:%d\"", fileExtension, uid)
return nil, errs.ErrImageTypeNotSupported
}
imageFile, err := imageFiles[0].Open()
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get image file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
defer imageFile.Close()
imageData, err := io.ReadAll(imageFile)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to read image file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
accountMap := a.accounts.GetVisibleAccountNameMapByList(accounts)
accountNames := make([]string, 0, len(accounts))
for i := 0; i < len(accounts); i++ {
if accounts[i].Hidden || accounts[i].Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
continue
}
accountNames = append(accountNames, accounts[i].Name)
}
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
incomeCategoryMap := make(map[string]*models.TransactionCategory)
incomeCategoryNames := make([]string, 0)
expenseCategoryMap := make(map[string]*models.TransactionCategory)
expenseCategoryNames := make([]string, 0)
transferCategoryMap := make(map[string]*models.TransactionCategory)
transferCategoryNames := make([]string, 0)
for i := 0; i < len(categories); i++ {
category := categories[i]
if category.Hidden || category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
continue
}
if category.Type == models.CATEGORY_TYPE_INCOME {
incomeCategoryMap[category.Name] = category
incomeCategoryNames = append(incomeCategoryNames, category.Name)
} else if category.Type == models.CATEGORY_TYPE_EXPENSE {
expenseCategoryMap[category.Name] = category
expenseCategoryNames = append(expenseCategoryNames, category.Name)
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
transferCategoryMap[category.Name] = category
transferCategoryNames = append(transferCategoryNames, category.Name)
}
}
tags, err := a.transactionTags.GetAllTagsByUid(c, uid)
if err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
tagNames := make([]string, 0, len(tags))
for i := 0; i < len(tags); i++ {
if tags[i].Hidden {
continue
}
tagNames = append(tagNames, tags[i].Name)
}
systemPrompt, err := templates.GetTemplate(templates.SYSTEM_PROMPT_RECEIPT_IMAGE_RECOGNITION)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
systemPromptParams := map[string]any{
"CurrentDateTime": utils.FormatUnixTimeToLongDateTime(time.Now().Unix(), timezone),
"AllExpenseCategoryNames": strings.Join(expenseCategoryNames, "\n"),
"AllIncomeCategoryNames": strings.Join(incomeCategoryNames, "\n"),
"AllTransferCategoryNames": strings.Join(transferCategoryNames, "\n"),
"AllAccountNames": strings.Join(accountNames, "\n"),
"AllTagNames": strings.Join(tagNames, "\n"),
}
var bodyBuffer bytes.Buffer
err = systemPrompt.Execute(&bodyBuffer, systemPromptParams)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
llmRequest := &data.LargeLanguageModelRequest{
Stream: false,
SystemPrompt: strings.ReplaceAll(bodyBuffer.String(), "\r\n", "\n"),
UserPrompt: imageData,
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
UserPromptContentType: contentType,
}
llmResponse, err := llm.Container.GetJsonResponseByReceiptImageRecognitionModel(c, c.GetCurrentUid(), a.CurrentConfig(), llmRequest)
if err != nil {
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if llmResponse == nil || len(llmResponse.Content) == 0 || strings.HasPrefix(llmResponse.Content, "{}") {
return nil, errs.ErrNoTransactionInformationInImage
}
var result *models.RecognizedReceiptImageResult
if err := json.Unmarshal([]byte(llmResponse.Content), &result); err != nil {
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to unmarshal recognized receipt image result from llm response \"%s\" for user \"uid:%d\", because %s", llmResponse.Content, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return a.parseRecognizedReceiptImageResponse(c, uid, utcOffset, result, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (a *LargeLanguageModelsApi) parseRecognizedReceiptImageResponse(c *core.WebContext, uid int64, utcOffset int16, recognizedResult *models.RecognizedReceiptImageResult, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (*models.RecognizedReceiptImageResponse, *errs.Error) {
recognizedReceiptImageResponse := &models.RecognizedReceiptImageResponse{
Type: models.TRANSACTION_TYPE_EXPENSE,
}
if recognizedResult == nil {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed result is null")
return nil, errs.ErrNoTransactionInformationInImage
}
if recognizedResult.Type == "income" {
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_INCOME
if len(recognizedResult.CategoryName) > 0 {
category, exists := incomeCategoryMap[recognizedResult.CategoryName]
if exists {
recognizedReceiptImageResponse.CategoryId = category.CategoryId
}
}
} else if recognizedResult.Type == "expense" {
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_EXPENSE
if len(recognizedResult.CategoryName) > 0 {
category, exists := expenseCategoryMap[recognizedResult.CategoryName]
if exists {
recognizedReceiptImageResponse.CategoryId = category.CategoryId
}
}
} else if recognizedResult.Type == "transfer" {
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_TRANSFER
if len(recognizedResult.CategoryName) > 0 {
category, exists := transferCategoryMap[recognizedResult.CategoryName]
if exists {
recognizedReceiptImageResponse.CategoryId = category.CategoryId
}
}
} else if len(recognizedResult.Type) == 0 {
return nil, errs.ErrNoTransactionInformationInImage
} else {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed transaction type \"%s\" is invalid", recognizedResult.Type)
return nil, errs.ErrOperationFailed
}
if len(recognizedResult.Time) > 0 {
longDateTime := a.getLongDateTime(recognizedResult.Time)
timestamp, err := utils.ParseFromLongDateTime(longDateTime, utcOffset)
if err != nil {
log.Warnf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed time \"%s\" is invalid", recognizedResult.Time)
} else {
recognizedReceiptImageResponse.Time = timestamp.Unix()
}
}
if len(recognizedResult.Amount) > 0 {
amount, err := utils.ParseAmount(recognizedResult.Amount)
if err != nil {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed amount \"%s\" is invalid", recognizedResult.Amount)
return nil, errs.ErrOperationFailed
}
recognizedReceiptImageResponse.SourceAmount = amount
if recognizedReceiptImageResponse.Type == models.TRANSACTION_TYPE_TRANSFER && len(recognizedResult.DestinationAmount) > 0 {
destinationAmount, err := utils.ParseAmount(recognizedResult.DestinationAmount)
if err != nil {
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed destination amount \"%s\" is invalid", recognizedResult.DestinationAmount)
return nil, errs.ErrOperationFailed
}
recognizedReceiptImageResponse.DestinationAmount = destinationAmount
}
}
if len(recognizedResult.AccountName) > 0 {
account, exists := accountMap[recognizedResult.AccountName]
if exists {
recognizedReceiptImageResponse.SourceAccountId = account.AccountId
}
}
if len(recognizedResult.DestinationAccountName) > 0 {
account, exists := accountMap[recognizedResult.DestinationAccountName]
if exists {
recognizedReceiptImageResponse.DestinationAccountId = account.AccountId
}
}
if len(recognizedResult.TagNames) > 0 {
tagIds := make([]string, 0, len(recognizedResult.TagNames))
for i := 0; i < len(recognizedResult.TagNames); i++ {
tagName := recognizedResult.TagNames[i]
tag, exists := tagMap[tagName]
if exists {
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
}
}
recognizedReceiptImageResponse.TagIds = tagIds
}
if len(recognizedResult.Description) > 0 {
recognizedReceiptImageResponse.Comment = recognizedResult.Description
}
return recognizedReceiptImageResponse, nil
}
func (a *LargeLanguageModelsApi) getLongDateTime(dateTime string) string {
if utils.IsValidLongDateTimeFormat(dateTime) {
return dateTime
}
if utils.IsValidLongDateTimeWithoutSecondFormat(dateTime) {
return dateTime + ":00"
}
if utils.IsValidLongDateFormat(dateTime) {
return dateTime + " 00:00:00"
}
return dateTime
}
+1 -3
View File
@@ -3,8 +3,6 @@ package api
import ( import (
"encoding/json" "encoding/json"
"github.com/gin-gonic/gin"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/log"
@@ -233,7 +231,7 @@ func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCReq
// PingHandler return the ping response for model context protocol // PingHandler return the ping response for model context protocol
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) { func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
return gin.H{}, nil return core.O{}, nil
} }
// GetTransactionService implements the MCPAvailableServices interface // GetTransactionService implements the MCPAvailableServices interface
+6
View File
@@ -47,6 +47,12 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer) a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
} }
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
if config.TransactionFromAIImageRecognition {
a.appendBooleanSetting(builder, "llmt", config.TransactionFromAIImageRecognition)
}
}
if config.LoginPageTips.Enabled { if config.LoginPageTips.Enabled {
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips) a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
} }
+7 -1
View File
@@ -130,7 +130,13 @@ func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Erro
// TokenRevokeCurrentHandler revokes current token of current user // TokenRevokeCurrentHandler revokes current token of current user
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) { func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
_, claims, err := a.tokens.ParseTokenByHeader(c) tokenString := c.GetTokenStringFromHeader()
if tokenString == "" {
return false, errs.ErrTokenIsEmpty
}
_, claims, err := a.tokens.ParseToken(c, tokenString)
if err != nil { if err != nil {
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err)) return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
+141 -4
View File
@@ -22,6 +22,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/utils"
) )
const pageCountForAccountStatement = 1000
// TransactionsApi represents transaction api // TransactionsApi represents transaction api
type TransactionsApi struct { type TransactionsApi struct {
ApiUsingConfig ApiUsingConfig
@@ -286,6 +288,114 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
return transactionResps, nil return transactionResps, nil
} }
// TransactionReconciliationStatementHandler returns transaction reconciliation statement list of current user
func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebContext) (any, *errs.Error) {
var reconciliationStatementRequest models.TransactionReconciliationStatementRequest
err := c.ShouldBindQuery(&reconciliationStatementRequest)
if err != nil {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
utcOffset, err := c.GetClientTimezoneOffset()
if err != nil {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] cannot get client timezone offset, because %s", err.Error())
return nil, errs.ErrClientTimezoneOffsetInvalid
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
account, err := a.accounts.GetAccountByAccountId(c, uid, reconciliationStatementRequest.AccountId)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.AccountId, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if account.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] account \"id:%d\" for user \"uid:%d\" is not a single account", reconciliationStatementRequest.AccountId, uid)
return nil, errs.ErrAccountTypeInvalid
}
maxTransactionTime := int64(0)
if reconciliationStatementRequest.EndTime > 0 {
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(reconciliationStatementRequest.EndTime)
}
minTransactionTime := int64(0)
if reconciliationStatementRequest.StartTime > 0 {
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(reconciliationStatementRequest.StartTime)
}
transactionsWithAccountBalance, totalInflows, totalOutflows, openingBalance, closingBalance, err := a.transactions.GetAllTransactionsWithAccountBalanceByMaxTime(c, uid, pageCountForAccountStatement, maxTransactionTime, minTransactionTime, reconciliationStatementRequest.AccountId, account.Category)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.StartTime, reconciliationStatementRequest.EndTime, uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
transactions := make([]*models.Transaction, len(transactionsWithAccountBalance))
transactionAccountBalanceMap := make(map[int64]*models.TransactionWithAccountBalance, len(transactionsWithAccountBalance))
for i := 0; i < len(transactionsWithAccountBalance); i++ {
transactionWithBalance := transactionsWithAccountBalance[i]
transactions[i] = transactionWithBalance.Transaction
transactionAccountBalanceMap[transactionWithBalance.TransactionId] = transactionWithBalance
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
}
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, utcOffset, false, true, true, true)
if err != nil {
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
responseItems := make([]*models.TransactionReconciliationStatementResponseItem, len(transactionResult))
for i := 0; i < len(transactionResult); i++ {
transactionResult := transactionResult[i]
accountOpeningBalance := int64(0)
accountClosingBalance := int64(0)
if transactionWithBalance, exists := transactionAccountBalanceMap[transactionResult.Id]; exists {
accountOpeningBalance = transactionWithBalance.AccountOpeningBalance
accountClosingBalance = transactionWithBalance.AccountClosingBalance
} else {
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] missing account balance for transaction \"id:%d\" of user \"uid:%d\"", transactionResult.Id, uid)
}
responseItems[i] = &models.TransactionReconciliationStatementResponseItem{
TransactionInfoResponse: transactionResult,
AccountOpeningBalance: accountOpeningBalance,
AccountClosingBalance: accountClosingBalance,
}
}
reconciliationStatementResp := &models.TransactionReconciliationStatementResponse{
Transactions: responseItems,
TotalInflows: totalInflows,
TotalOutflows: totalOutflows,
OpeningBalance: openingBalance,
ClosingBalance: closingBalance,
}
return reconciliationStatementResp, nil
}
// TransactionStatisticsHandler returns transaction statistics of current user // TransactionStatisticsHandler returns transaction statistics of current user
func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any, *errs.Error) { func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
var statisticReq models.TransactionStatisticRequest var statisticReq models.TransactionStatisticRequest
@@ -439,6 +549,25 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
return nil, errs.ErrQueryItemsTooMuch return nil, errs.ErrQueryItemsTooMuch
} }
excludeAccountIds := make([]int64, 0)
excludeCategoryIds := make([]int64, 0)
if transactionAmountsReq.ExcludeAccountIds != "" {
excludeAccountIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeAccountIds, ","))
if err != nil {
return nil, errs.ErrAccountIdInvalid
}
}
if transactionAmountsReq.ExcludeCategoryIds != "" {
excludeCategoryIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeCategoryIds, ","))
if err != nil {
return nil, errs.ErrTransactionCategoryIdInvalid
}
}
utcOffset, err := c.GetClientTimezoneOffset() utcOffset, err := c.GetClientTimezoneOffset()
if err != nil { if err != nil {
@@ -461,7 +590,7 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
for i := 0; i < len(requestItems); i++ { for i := 0; i < len(requestItems); i++ {
requestItem := requestItems[i] requestItem := requestItems[i]
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, utcOffset, transactionAmountsReq.UseTransactionTimezone) incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, excludeAccountIds, excludeCategoryIds, utcOffset, transactionAmountsReq.UseTransactionTimezone)
if err != nil { if err != nil {
log.Errorf(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error())
@@ -699,7 +828,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid return nil, errs.ErrTransactionTypeInvalid
} }
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 { if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
log.Warnf(c, "[transactions.TransactionCreateHandler] balance modification transaction cannot set category id") log.Warnf(c, "[transactions.TransactionCreateHandler] balance modification transaction cannot set category id")
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
} }
@@ -847,6 +976,14 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid return nil, errs.ErrTransactionTypeInvalid
} }
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId != 0 {
log.Warnf(c, "[transactions.TransactionModifyHandler] balance modification transaction cannot set category id")
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
} else if transaction.Type != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId == 0 {
log.Warnf(c, "[transactions.TransactionModifyHandler] non-balance modification transaction must set category id")
return nil, errs.ErrIncompleteOrIncorrectSubmission
}
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, []int64{transaction.TransactionId}) allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, []int64{transaction.TransactionId})
if err != nil { if err != nil {
@@ -1310,7 +1447,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
tagMap := a.transactionTags.GetTagNameMapByList(tags) tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
@@ -1388,7 +1525,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
return nil, errs.ErrTransactionTypeInvalid return nil, errs.ErrTransactionTypeInvalid
} }
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 { if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i) log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
} }
+99 -84
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -74,115 +75,129 @@ func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *co
return false, errs.ErrNotPermittedToPerformThisAction return false, errs.ErrNotPermittedToPerformThisAction
} }
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid) var userApplicationCloudSettings *models.UserApplicationCloudSetting
if err != nil { // Retry up to 3 times
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error()) for i := 0; i < 3; i++ {
return false, errs.Or(err, errs.ErrOperationFailed) userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
}
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting) if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\" (try count %d), because %s", uid, i+1, err.Error())
if userApplicationCloudSettings != nil { return false, errs.Or(err, errs.ErrOperationFailed)
for _, setting := range userApplicationCloudSettings.Settings {
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
} }
}
// Check if the full update settings are the same as the existing settings oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
if userAppCloudSettingUpdateReq.FullUpdate { lastUpdateTime := int64(0)
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
needUpdate := false if userApplicationCloudSettings != nil {
for _, setting := range userApplicationCloudSettings.Settings {
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
}
lastUpdateTime = userApplicationCloudSettings.UpdatedUnixTime
}
// Check if the full update settings are the same as the existing settings
if userAppCloudSettingUpdateReq.FullUpdate {
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
needUpdate := false
for _, setting := range userAppCloudSettingUpdateReq.Settings {
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
if !exists || oldSetting.SettingValue != setting.SettingValue {
needUpdate = true
break
}
}
if !needUpdate {
return false, errs.ErrNothingWillBeUpdated
}
}
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
needUpdate := true
for _, setting := range userAppCloudSettingUpdateReq.Settings { for _, setting := range userAppCloudSettingUpdateReq.Settings {
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey] cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
if !exists || oldSetting.SettingValue != setting.SettingValue { if !exists {
needUpdate = true needUpdate = false
break log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync (try count %d)", setting.SettingKey, i+1)
} else if cloudSetting.SettingValue == setting.SettingValue {
needUpdate = false
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update (try count %d)", setting.SettingKey, setting.SettingValue, i+1)
} }
} }
if !needUpdate { if !needUpdate {
return false, errs.ErrNothingWillBeUpdated log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\" (try count %d)", uid, i+1)
return true, nil
}
}
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
if userAppCloudSettingUpdateReq.FullUpdate {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings (try count %d)", uid, i+1)
} else {
if len(oldApplicationCloudSettingsMap) > 0 {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings (try count %d)", uid, i+1)
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
} }
} }
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
needUpdate := true
for _, setting := range userAppCloudSettingUpdateReq.Settings { for _, setting := range userAppCloudSettingUpdateReq.Settings {
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey] newApplicationCloudSettingsMap[setting.SettingKey] = setting
}
for settingKey, setting := range newApplicationCloudSettingsMap {
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
if !exists { if !exists {
needUpdate = false log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync (try count %d)", settingKey, i+1)
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync", setting.SettingKey)
} else if cloudSetting.SettingValue == setting.SettingValue {
needUpdate = false
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update", setting.SettingKey, setting.SettingValue)
}
}
if !needUpdate {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\"", uid)
return false, nil
}
}
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
if userAppCloudSettingUpdateReq.FullUpdate {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings", uid)
} else {
if len(oldApplicationCloudSettingsMap) > 0 {
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings", uid)
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
}
}
for _, setting := range userAppCloudSettingUpdateReq.Settings {
newApplicationCloudSettingsMap[setting.SettingKey] = setting
}
for settingKey, setting := range newApplicationCloudSettingsMap {
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
if !exists {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync", settingKey)
continue
}
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
// Do Nothing
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
_, err := utils.StringToFloat64(setting.SettingValue)
if err != nil {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\"", settingKey, setting.SettingValue)
continue continue
} }
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
if setting.SettingValue != "true" && setting.SettingValue != "false" {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\"", settingKey, setting.SettingValue)
continue
}
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
var settingValueMap map[string]bool
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
if err != nil { if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\", because %s", settingKey, setting.SettingValue, err.Error()) // Do Nothing
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
_, err := utils.StringToFloat64(setting.SettingValue)
if err != nil {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
continue
}
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
if setting.SettingValue != "true" && setting.SettingValue != "false" {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
continue
}
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
var settingValueMap map[string]bool
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
if err != nil {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\" (try count %d), because %s", settingKey, setting.SettingValue, i+1, err.Error())
continue
}
} else {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\" (try count %d)", settingKey, settingType, i+1)
continue continue
} }
} else {
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\"", settingKey, settingType) newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
continue
} }
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting) err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
}
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice) if err == nil {
break
}
time.Sleep(100 * time.Millisecond) // Wait for 100 milliseconds before retrying
}
if err != nil { if err != nil {
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
+36 -9
View File
@@ -359,6 +359,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
} }
if userUpdateReq.CalendarDisplayType != nil && *userUpdateReq.CalendarDisplayType != user.CalendarDisplayType {
user.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
userNew.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.CalendarDisplayType = core.CALENDAR_DISPLAY_TYPE_INVALID
}
if userUpdateReq.DateDisplayType != nil && *userUpdateReq.DateDisplayType != user.DateDisplayType {
user.DateDisplayType = *userUpdateReq.DateDisplayType
userNew.DateDisplayType = *userUpdateReq.DateDisplayType
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.DateDisplayType = core.DATE_DISPLAY_TYPE_INVALID
}
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat { if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
user.LongDateFormat = *userUpdateReq.LongDateFormat user.LongDateFormat = *userUpdateReq.LongDateFormat
userNew.LongDateFormat = *userUpdateReq.LongDateFormat userNew.LongDateFormat = *userUpdateReq.LongDateFormat
@@ -404,6 +422,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_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.NumeralSystem != nil && *userUpdateReq.NumeralSystem != user.NumeralSystem {
user.NumeralSystem = *userUpdateReq.NumeralSystem
userNew.NumeralSystem = *userUpdateReq.NumeralSystem
modifyProfileBasicInfo = true
anythingUpdate = true
} else {
userNew.NumeralSystem = core.NUMERAL_SYSTEM_INVALID
}
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator { if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
user.DecimalSeparator = *userUpdateReq.DecimalSeparator user.DecimalSeparator = *userUpdateReq.DecimalSeparator
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
@@ -431,15 +467,6 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID 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 { if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
+9 -5
View File
@@ -9,7 +9,7 @@ import (
// AvatarProviderContainer contains the current user avatar provider // AvatarProviderContainer contains the current user avatar provider
type AvatarProviderContainer struct { type AvatarProviderContainer struct {
Current AvatarProvider current AvatarProvider
} }
// Initialize a user avatar provider container singleton instance // Initialize a user avatar provider container singleton instance
@@ -20,13 +20,13 @@ var (
// InitializeAvatarProvider initializes the current user avatar provider according to the config // InitializeAvatarProvider initializes the current user avatar provider according to the config
func InitializeAvatarProvider(config *settings.Config) error { func InitializeAvatarProvider(config *settings.Config) error {
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL { if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
Container.Current = NewInternalStorageAvatarProvider(config) Container.current = NewInternalStorageAvatarProvider(config)
return nil return nil
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR { } else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
Container.Current = NewGravatarAvatarProvider() Container.current = NewGravatarAvatarProvider()
return nil return nil
} else if config.AvatarProvider == "" { } else if config.AvatarProvider == "" {
Container.Current = NewNullAvatarProvider() Container.current = NewNullAvatarProvider()
return nil return nil
} }
@@ -35,5 +35,9 @@ func InitializeAvatarProvider(config *settings.Config) error {
// GetAvatarUrl returns the avatar url by the current user avatar provider // GetAvatarUrl returns the avatar url by the current user avatar provider
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string { func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
return p.Current.GetAvatarUrl(user) if p.current == nil {
return ""
}
return p.current.GetAvatarUrl(user)
} }
+1 -1
View File
@@ -9,5 +9,5 @@ type CliUsingConfig struct {
// CurrentConfig returns the current config // CurrentConfig returns the current config
func (l *CliUsingConfig) CurrentConfig() *settings.Config { func (l *CliUsingConfig) CurrentConfig() *settings.Config {
return l.container.Current return l.container.GetCurrentConfig()
} }
+34 -1
View File
@@ -445,6 +445,39 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
return tokenRecord, token, nil return tokenRecord, token, nil
} }
// RevokeUserToken revokes the specified token of the user
func (l *UserDataCli) RevokeUserToken(c *core.CliContext, token string) error {
_, claims, err := l.tokens.ParseToken(c, token)
if err != nil {
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to parse token, because %s", err.Error())
return err
}
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
if err != nil {
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to get user token id, because %s", err.Error())
return err
}
tokenRecord := &models.TokenRecord{
Uid: claims.Uid,
UserTokenId: userTokenId,
CreatedUnixTime: claims.IssuedAt,
}
tokenId := l.tokens.GenerateTokenId(tokenRecord)
err = l.tokens.DeleteToken(c, tokenRecord)
if err != nil {
log.Errorf(c, "[user_data.RevokeUserToken] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
return err
}
return nil
}
// ClearUserTokens clears all tokens of the specified user // ClearUserTokens clears all tokens of the specified user
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error { func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
if username == "" { if username == "" {
@@ -924,7 +957,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
return nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, err
} }
tagMap = l.tags.GetTagNameMapByList(tags) tagMap = l.tags.GetVisibleTagNameMapByList(tags)
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
} }
@@ -2,21 +2,17 @@ package alipay
import ( import (
"bytes" "bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform" "golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter" "github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv" "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable" "github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
) )
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{ var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
@@ -61,7 +57,13 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
enc := simplifiedchinese.GB18030 enc := simplifiedchinese.GB18030
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder()) reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
dataTable, err := c.createNewAlipayBasicDataTable(ctx, reader, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune) csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
if err != nil { if err != nil {
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
@@ -83,80 +85,3 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
} }
func (c *alipayTransactionDataCsvFileImporter) createNewAlipayBasicDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, 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_data_csv_file_importer.createNewAlipayBasicDataTable] 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_data_csv_file_importer.createNewAlipayBasicDataTable] 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_data_csv_file_importer.createNewAlipayBasicDataTable] 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_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
return dataTable, nil
}
@@ -102,6 +102,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
// refund
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" + data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" + "账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" + "起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
@@ -121,6 +122,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName) assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName) assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
// tax refund
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" + data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
"账号:[xxx@xxx.xxx]\n" + "账号:[xxx@xxx.xxx]\n" +
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" + "起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
@@ -141,6 +143,46 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName) assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
} }
func TestAlipayCsvFileImporterParseImportedData_ParseInvestmentRefundTransaction(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:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,退款成功,\n" +
"2024-09-01 02:00:00,Test Account2,xxx-买入退款,不计收支,0.01,Test Account,退款成功,\n")
allNewTransactions, _, _, _, _, _, 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, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
assert.Equal(t, "2024-09-01 02:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
}
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) { func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := AlipayWebTransactionDataCsvFileImporter converter := AlipayWebTransactionDataCsvFileImporter
context := core.NewNullContext() context := core.NewNullContext()
@@ -380,38 +422,110 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" + "起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
"导出交易类型:[全部]\n" + "导出交易类型:[全部]\n" +
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" + "------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
"交易时间,商品说明,收/支,金额,收/付款方式,交易状态,\n" + "交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,备注,\n" +
"2024-09-01 03:45:07,余额宝-单次转入,不计收支,0.01,Test Account,交易成功,\n" + "2024-09-01 00:00:00,xxx,xxx-收益发放,不计收支,0.01,Test Account,交易成功,earning,\n" +
"2024-09-01 05:07:29,信用卡还款,不计收支,0.02,Test Account2,交易成功,\n") "2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,交易成功,purchase investment,\n" +
"2024-09-01 02:00:00,Test Account2,xxx-卖出至xxx,不计收支,0.01,Test Account,交易成功,sell investment,\n" +
"2024-09-01 03:00:00,xxx,充值-普通充值,不计收支,0.01,Test Account,交易成功,transfer to alipay wallet,\n" +
"2024-09-01 04:00:00,Test Account3,提现-实时提现,不计收支,0.01,Test Account,交易成功,transfer from alipay wallet,\n" +
"2024-09-01 05:00:00,Test Account3,xxx-单次转入,不计收支,0.01,Test Account,交易成功,transfer in,\n" +
"2024-09-01 06:00:00,Test Account3,xxx-转出到银行卡,不计收支,0.01,Test Account,交易成功,transfer out,\n" +
"2024-09-01 07:00:00,Test Account3,转账xxx,不计收支,0.01,Test Account,交易成功,transfer,\n" +
"2024-09-01 08:00:00,Test Account4,信用卡还款,不计收支,0.01,Test Account,还款成功,repayment,\n")
assert.Nil(t, err) assert.Nil(t, err)
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil) allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions)) assert.Equal(t, 9, len(allNewTransactions))
assert.Equal(t, 3, len(allNewAccounts)) assert.Equal(t, 6, len(allNewAccounts))
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid) assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, int64(1), allNewTransactions[0].Amount) assert.Equal(t, int64(1), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName) assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName) assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, "earning", allNewTransactions[0].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid) assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, int64(2), allNewTransactions[1].Amount) assert.Equal(t, int64(1), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName) assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName) assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalDestinationAccountName)
assert.Equal(t, "purchase investment", allNewTransactions[1].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, int64(1), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalDestinationAccountName)
assert.Equal(t, "sell investment", allNewTransactions[2].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, int64(1), allNewTransactions[3].Amount)
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "transfer to alipay wallet", allNewTransactions[3].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
assert.Equal(t, int64(1), allNewTransactions[4].Amount)
assert.Equal(t, "Alipay", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "transfer from alipay wallet", allNewTransactions[4].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, int64(1), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[5].OriginalDestinationAccountName)
assert.Equal(t, "transfer in", allNewTransactions[5].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[6].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
assert.Equal(t, int64(1), allNewTransactions[6].Amount)
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[6].OriginalDestinationAccountName)
assert.Equal(t, "transfer out", allNewTransactions[6].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[7].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
assert.Equal(t, int64(1), allNewTransactions[7].Amount)
assert.Equal(t, "Test Account", allNewTransactions[7].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[7].OriginalDestinationAccountName)
assert.Equal(t, "transfer", allNewTransactions[7].Comment)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[8].Type)
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
assert.Equal(t, int64(1), allNewTransactions[8].Amount)
assert.Equal(t, "Test Account", allNewTransactions[8].OriginalSourceAccountName)
assert.Equal(t, "Test Account4", allNewTransactions[8].OriginalDestinationAccountName)
assert.Equal(t, "repayment", allNewTransactions[8].Comment)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid) assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name) assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency) assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid) assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "", allNewAccounts[1].Name) assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency) assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid) assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[2].Name) assert.Equal(t, "", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency) assert.Equal(t, "CNY", allNewAccounts[2].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
assert.Equal(t, "Alipay", allNewAccounts[3].Name)
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[4].Uid)
assert.Equal(t, "Test Account3", allNewAccounts[4].Name)
assert.Equal(t, "CNY", allNewAccounts[4].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[5].Uid)
assert.Equal(t, "Test Account4", allNewAccounts[5].Name)
assert.Equal(t, "CNY", allNewAccounts[5].Currency)
} }
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) { func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
@@ -0,0 +1,78 @@
package alipay
import (
"strings"
"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/utils"
)
func createNewAlipayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
iterator := originalDataTable.DataRowIterator()
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for iterator.HasNext() {
row := iterator.Next()
if !hasFileHeader {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), fileHeaderLine) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
continue
}
}
if !foundContentBeforeDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), dataHeaderStartContent) >= 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
} else if row.ColumnCount() == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(row.GetData(0), dataBottomEndLineRune) {
break
}
items := make([]string, row.ColumnCount())
for i := 0; i < row.ColumnCount(); i++ {
items[i] = strings.Trim(row.GetData(i), " ")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), 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_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
}
@@ -18,10 +18,15 @@ const alipayTransactionDataStatusClosedName = "交易关闭"
const alipayTransactionDataStatusRefundSuccessName = "退款成功" const alipayTransactionDataStatusRefundSuccessName = "退款成功"
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功" const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
const alipayTransactionDataProductNameEarningText = "-收益发放"
const alipayTransactionDataProductNamePurchaseInvestmentText = "-买入"
const alipayTransactionDataProductNamePurchaseInvestmentRefundText = "-买入退款"
const alipayTransactionDataProductNameSellInvestmentRefundText = "-卖出"
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-" const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-" const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
const alipayTransactionDataProductNameTransferInText = "转入" const alipayTransactionDataProductNameTransferInText = "转入"
const alipayTransactionDataProductNameTransferOutText = "转出" const alipayTransactionDataProductNameTransferOutText = "转出"
const alipayTransactionDataProductNameTransferText = "转账"
const alipayTransactionDataProductNameRepaymentText = "还款" const alipayTransactionDataProductNameRepaymentText = "还款"
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser // alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
@@ -127,11 +132,29 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
} }
if statusName == alipayTransactionDataStatusRefundSuccessName { if statusName == alipayTransactionDataStatusRefundSuccessName {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = "" data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentRefundText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) { // purchase investment refund
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
} else {
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 { } else {
if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet if len(productName) > len(alipayTransactionDataProductNameEarningText) && strings.Index(productName, alipayTransactionDataProductNameEarningText) == len(productName)-len(alipayTransactionDataProductNameEarningText) { // earning
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] = targetName
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameSellInvestmentRefundText) >= 0 { // sell investment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = "" data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet } else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
@@ -143,6 +166,9 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out } else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
} else if strings.Index(productName, alipayTransactionDataProductNameTransferText) >= 0 { // transfer
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 } else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
@@ -1,15 +1,20 @@
package beancount package beancount
import ( import (
"fmt" "math/big"
"strconv"
"strings" "strings"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/utils"
) )
const maxAllowedDecimalCount = 6
const normalizeFactor = int64(1000000)
const normalizedDecimalsMaxZeroString = "000000"
const normalizedNumberToAmountFactor = int64(10000) // 1000000 / 100
var operatorPriority = map[rune]int{ var operatorPriority = map[rune]int{
'+': 1, '+': 1,
'-': 1, '-': 1,
@@ -17,6 +22,44 @@ var operatorPriority = map[rune]int{
'/': 2, '/': 2,
} }
func normalizeNumber(textualNumber string) (*big.Int, error) {
decimalSeparatorPos := strings.Index(textualNumber, ".")
if decimalSeparatorPos < 0 {
result := big.NewInt(0)
_, ok := result.SetString(textualNumber+normalizedDecimalsMaxZeroString, 10)
if !ok {
return nil, errs.ErrAmountInvalid
}
return result, nil
}
integer := utils.SubString(textualNumber, 0, decimalSeparatorPos)
decimals := utils.SubString(textualNumber, decimalSeparatorPos+1, len(textualNumber))
if len(decimals) > maxAllowedDecimalCount {
return nil, errs.ErrAmountInvalid
}
paddedDecimals := utils.SubString(decimals+normalizedDecimalsMaxZeroString, 0, maxAllowedDecimalCount)
result := big.NewInt(0)
_, ok := result.SetString(integer+paddedDecimals, 10)
if !ok {
return nil, errs.ErrAmountInvalid
}
return result, nil
}
func denormalizeNumberToTextualAmount(num *big.Int) string {
result := big.NewInt(0).Add(num, big.NewInt(0)) // make a copy of num
result = result.Div(result, big.NewInt(normalizedNumberToAmountFactor))
return utils.FormatAmount(result.Int64())
}
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) { func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
finalTokens := make([]string, 0) finalTokens := make([]string, 0)
operatorStack := make([]rune, 0) operatorStack := make([]rune, 0)
@@ -117,8 +160,8 @@ func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
return finalTokens, nil return finalTokens, nil
} }
func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) { func evaluatePostfixExpr(ctx core.Context, tokens []string) (*big.Int, error) {
stack := make([]float64, 0) stack := make([]*big.Int, 0)
for i := 0; i < len(tokens); i++ { for i := 0; i < len(tokens); i++ {
token := tokens[i] token := tokens[i]
@@ -127,7 +170,7 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
case "+", "-", "*", "/": // operators case "+", "-", "*", "/": // operators
if len(stack) < 2 { if len(stack) < 2 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " ")) log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression return nil, errs.ErrInvalidAmountExpression
} }
// pop the top two operands // pop the top two operands
@@ -138,39 +181,41 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
stack = stack[:len(stack)-1] stack = stack[:len(stack)-1]
// evaluate the operation // evaluate the operation
var result float64 result := big.NewInt(0)
switch token { switch token {
case "+": case "+":
result = a + b result.Add(a, b)
case "-": case "-":
result = a - b result.Sub(a, b)
case "*": case "*":
result = a * b result.Mul(a, b)
result.Div(result, big.NewInt(normalizeFactor))
case "/": case "/":
if b == 0 { if b.Int64() == 0 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " ")) log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression return nil, errs.ErrInvalidAmountExpression
} }
result = a / b result.Mul(a, big.NewInt(normalizeFactor))
result.Div(result, b)
} }
// push the result back to the stack // push the result back to the stack
stack = append(stack, result) stack = append(stack, result)
default: // operands default: // operands
num, err := strconv.ParseFloat(token, 64) normalizedNum, err := normalizeNumber(token)
if err != nil { if err != nil {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " ")) log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression return nil, errs.ErrInvalidAmountExpression
} }
stack = append(stack, num) stack = append(stack, normalizedNum)
} }
} }
if len(stack) != 1 { if len(stack) != 1 {
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " ")) log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
return 0, errs.ErrInvalidAmountExpression return nil, errs.ErrInvalidAmountExpression
} }
return stack[0], nil return stack[0], nil
@@ -193,5 +238,5 @@ func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, e
return "", err return "", err
} }
return fmt.Sprintf("%.2f", result), nil return denormalizeNumberToTextualAmount(result), nil
} }
@@ -1,6 +1,7 @@
package beancount package beancount
import ( import (
"math/big"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -97,23 +98,23 @@ func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"}) result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, float64(3), result) assert.Equal(t, big.NewInt(3000000), result)
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"}) result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, float64(2), result) assert.Equal(t, big.NewInt(2000000), result)
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"}) result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, float64(12), result) assert.Equal(t, big.NewInt(12000000), result)
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"}) result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, float64(3), result) assert.Equal(t, big.NewInt(3000000), result)
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"}) result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, float64(5), result) assert.Equal(t, big.NewInt(5000000), result)
} }
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) { func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
@@ -179,6 +180,18 @@ func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))") result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, "10.00", result) assert.Equal(t, "10.00", result)
result, err = evaluateBeancountAmountExpression(context, "3.5+0.1")
assert.Nil(t, err)
assert.Equal(t, "3.60", result)
result, err = evaluateBeancountAmountExpression(context, "3.55+0.11")
assert.Nil(t, err)
assert.Equal(t, "3.66", result)
result, err = evaluateBeancountAmountExpression(context, "3.555+0.111")
assert.Nil(t, err)
assert.Equal(t, "3.66", result)
} }
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) { func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
@@ -213,4 +226,10 @@ func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
_, err = evaluateBeancountAmountExpression(context, "1)*(2") _, err = evaluateBeancountAmountExpression(context, "1)*(2")
assert.Equal(t, errs.ErrInvalidAmountExpression, err) assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "0.abcd+1")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
_, err = evaluateBeancountAmountExpression(context, "0.1234567+1")
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
} }
+27 -10
View File
@@ -13,7 +13,8 @@ import (
// CsvFileBasicDataTable defines the structure of csv data table // CsvFileBasicDataTable defines the structure of csv data table
type CsvFileBasicDataTable struct { type CsvFileBasicDataTable struct {
allLines [][]string allLines [][]string
hasTitleLine bool
} }
// CsvFileBasicDataTableRow defines the structure of csv data table row // CsvFileBasicDataTableRow defines the structure of csv data table row
@@ -34,7 +35,11 @@ func (t *CsvFileBasicDataTable) DataRowCount() int {
return 0 return 0
} }
return len(t.allLines) - 1 if t.hasTitleLine {
return len(t.allLines) - 1
} else {
return len(t.allLines)
}
} }
// HeaderColumnNames returns the header column name list // HeaderColumnNames returns the header column name list
@@ -43,14 +48,24 @@ func (t *CsvFileBasicDataTable) HeaderColumnNames() []string {
return nil return nil
} }
return t.allLines[0] if t.hasTitleLine {
return t.allLines[0]
} else {
return nil
}
} }
// DataRowIterator returns the iterator of data row // DataRowIterator returns the iterator of data row
func (t *CsvFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator { func (t *CsvFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
startIndex := -1
if t.hasTitleLine {
startIndex = 0
}
return &CsvFileBasicDataTableRowIterator{ return &CsvFileBasicDataTableRowIterator{
dataTable: t, dataTable: t,
currentIndex: 0, currentIndex: startIndex,
} }
} }
@@ -95,18 +110,19 @@ func (t *CsvFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
} }
// CreateNewCsvBasicDataTable returns comma separated values data table by io readers // CreateNewCsvBasicDataTable returns comma separated values data table by io readers
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) { func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader, hasTitleLine bool) (datatable.BasicDataTable, error) {
return createNewCsvFileBasicDataTable(ctx, reader, ',') return createNewCsvFileBasicDataTable(ctx, reader, ',', hasTitleLine)
} }
// CreateNewCustomCsvBasicDataTable returns character separated values data table by io readers // CreateNewCustomCsvBasicDataTable returns character separated values data table by io readers
func CreateNewCustomCsvBasicDataTable(allLines [][]string) datatable.BasicDataTable { func CreateNewCustomCsvBasicDataTable(allLines [][]string, hasTitleLine bool) datatable.BasicDataTable {
return &CsvFileBasicDataTable{ return &CsvFileBasicDataTable{
allLines: allLines, allLines: allLines,
hasTitleLine: hasTitleLine,
} }
} }
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune) (*CsvFileBasicDataTable, error) { func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune, hasTitleLine bool) (*CsvFileBasicDataTable, error) {
csvReader := csv.NewReader(reader) csvReader := csv.NewReader(reader)
csvReader.Comma = separator csvReader.Comma = separator
csvReader.FieldsPerRecord = -1 csvReader.FieldsPerRecord = -1
@@ -133,6 +149,7 @@ func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separato
} }
return &CsvFileBasicDataTable{ return &CsvFileBasicDataTable{
allLines: allLines, allLines: allLines,
hasTitleLine: hasTitleLine,
}, nil }, nil
} }
@@ -14,7 +14,17 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
{"A2", "B2", "C2"}, {"A2", "B2", "C2"},
{"A3", "B3", "C3"}, {"A3", "B3", "C3"},
}) }, false)
assert.Equal(t, 3, datatable.DataRowCount())
}
func TestCsvFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
}, true)
assert.Equal(t, 2, datatable.DataRowCount()) assert.Equal(t, 2, datatable.DataRowCount())
} }
@@ -22,14 +32,16 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) { func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{ datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
}) }, true)
assert.Equal(t, 0, datatable.DataRowCount()) assert.Equal(t, 0, datatable.DataRowCount())
} }
func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) { func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{}) datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
assert.Equal(t, 0, datatable.DataRowCount())
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
assert.Equal(t, 0, datatable.DataRowCount()) assert.Equal(t, 0, datatable.DataRowCount())
} }
@@ -38,14 +50,16 @@ func TestCsvFileBasicDataTableHeaderColumnNames(t *testing.T) {
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
{"A2", "B2", "C2"}, {"A2", "B2", "C2"},
{"A3", "B3", "C3"}, {"A3", "B3", "C3"},
}) }, true)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames()) assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
} }
func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) { func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{}) datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
assert.Nil(t, datatable.HeaderColumnNames())
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
assert.Nil(t, datatable.HeaderColumnNames()) assert.Nil(t, datatable.HeaderColumnNames())
} }
@@ -54,7 +68,34 @@ func TestCsvFileBasicDataTableRowIterator(t *testing.T) {
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
{"A2", "B2", "C2"}, {"A2", "B2", "C2"},
{"A3", "B3", "C3"}, {"A3", "B3", "C3"},
}) }, false)
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.True(t, iterator.HasNext())
// data row 3
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestCsvFileBasicDataTableRowIterator_HasTitleLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
}, true)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext()) assert.True(t, iterator.HasNext())
@@ -81,7 +122,7 @@ func TestCsvFileBasicDataTableRowColumnCount(t *testing.T) {
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
{"A2", "B2", "C2"}, {"A2", "B2", "C2"},
{"A3", "B3", "C3"}, {"A3", "B3", "C3"},
}) }, true)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
@@ -97,7 +138,32 @@ func TestCsvFileBasicDataTableRowGetData(t *testing.T) {
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
{"A2", "B2", "C2"}, {"A2", "B2", "C2"},
{"A3", "B3", "C3"}, {"A3", "B3", "C3"},
}) }, false)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A1", row1.GetData(0))
assert.Equal(t, "B1", row1.GetData(1))
assert.Equal(t, "C1", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A2", row2.GetData(0))
assert.Equal(t, "B2", row2.GetData(1))
assert.Equal(t, "C2", row2.GetData(2))
row3 := iterator.Next()
assert.Equal(t, "A3", row3.GetData(0))
assert.Equal(t, "B3", row3.GetData(1))
assert.Equal(t, "C3", row3.GetData(2))
}
func TestCsvFileBasicDataTableRowGetData_HasTitleLine(t *testing.T) {
datatable := CreateNewCustomCsvBasicDataTable([][]string{
{"A1", "B1", "C1"},
{"A2", "B2", "C2"},
{"A3", "B3", "C3"},
}, true)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
@@ -117,7 +183,7 @@ func TestCsvFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
{"A1", "B1", "C1"}, {"A1", "B1", "C1"},
{"A2", "B2", "C2"}, {"A2", "B2", "C2"},
{"A3", "B3", "C3"}, {"A3", "B3", "C3"},
}) }, true)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
@@ -130,7 +196,7 @@ func TestCreateNewCsvBasicDataTable(t *testing.T) {
reader := bytes.NewReader([]byte("A1,B1,C1\n" + reader := bytes.NewReader([]byte("A1,B1,C1\n" +
"A2,B2,C2\n" + "A2,B2,C2\n" +
"A3,B3,C3\n")) "A3,B3,C3\n"))
datatable, err := CreateNewCsvBasicDataTable(context, reader) datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount()) assert.Equal(t, 2, datatable.DataRowCount())
@@ -160,7 +226,7 @@ func TestCreateNewCsvBasicDataTable_SkipBlankLine(t *testing.T) {
"A2,B2,C2\n" + "A2,B2,C2\n" +
"\n" + "\n" +
"A3,B3,C3\n")) "A3,B3,C3\n"))
datatable, err := CreateNewCsvBasicDataTable(context, reader) datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount()) assert.Equal(t, 2, datatable.DataRowCount())
@@ -0,0 +1,65 @@
package datatable
type testBasicDataTable struct {
headerColumns []string
rows []*testBasicDataTableRow
}
type testBasicDataTableRow struct {
rowId string
rowColumns []string
}
type testBasicDataTableRowIterator struct {
rows []*testBasicDataTableRow
currentIndex int
}
func (t *testBasicDataTable) HeaderColumnNames() []string {
return t.headerColumns
}
func (t *testBasicDataTable) DataRowCount() int {
return len(t.rows)
}
func (t *testBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
return &testBasicDataTableRowIterator{
rows: t.rows,
currentIndex: -1,
}
}
func (r *testBasicDataTableRow) ColumnCount() int {
return len(r.rowColumns)
}
func (r *testBasicDataTableRow) GetData(columnIndex int) string {
if columnIndex < 0 || columnIndex >= len(r.rowColumns) {
return ""
}
return r.rowColumns[columnIndex]
}
func (t *testBasicDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.rows)
}
func (t *testBasicDataTableRowIterator) CurrentRowId() string {
if t.currentIndex >= len(t.rows) {
return ""
}
return t.rows[t.currentIndex].rowId
}
func (t *testBasicDataTableRowIterator) Next() BasicDataTableRow {
if t.currentIndex+1 >= len(t.rows) {
return nil
}
t.currentIndex++
row := t.rows[t.currentIndex]
return row
}
@@ -0,0 +1,108 @@
package datatable
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBasicDataTableToCommonDataTableWrapper_HeaderColumnCount(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: []*testBasicDataTableRow{},
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
assert.Equal(t, len(columns), commonDataTable.HeaderColumnCount())
}
func TestBasicDataTableToCommonDataTableWrapper_HasColumn(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: []*testBasicDataTableRow{},
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
assert.True(t, commonDataTable.HasColumn("Col1"))
assert.True(t, commonDataTable.HasColumn("Col2"))
assert.True(t, commonDataTable.HasColumn("Col3"))
assert.False(t, commonDataTable.HasColumn("Col4"))
assert.False(t, commonDataTable.HasColumn(""))
}
func TestBasicDataTableToCommonDataTableWrapper_DataRowCount(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
{
rowId: "3",
rowColumns: []string{"A3", "B3", "C3"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
assert.Equal(t, len(rows), commonDataTable.DataRowCount())
}
func TestBasicDataTableToCommonDataTableWrapper_DataRowIterator(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
iterator := commonDataTable.DataRowIterator()
assert.True(t, iterator.HasNext())
firstRow := iterator.Next()
assert.NotNil(t, firstRow)
assert.Equal(t, len(columns), firstRow.ColumnCount())
assert.True(t, firstRow.HasData("Col1"))
assert.True(t, firstRow.HasData("Col2"))
assert.True(t, firstRow.HasData("Col3"))
assert.Equal(t, "A1", firstRow.GetData("Col1"))
assert.Equal(t, "B1", firstRow.GetData("Col2"))
assert.Equal(t, "C1", firstRow.GetData("Col3"))
assert.True(t, iterator.HasNext())
secondRow := iterator.Next()
assert.NotNil(t, secondRow)
assert.Equal(t, len(columns), secondRow.ColumnCount())
assert.True(t, secondRow.HasData("Col1"))
assert.True(t, secondRow.HasData("Col2"))
assert.True(t, secondRow.HasData("Col3"))
assert.Equal(t, "A2", secondRow.GetData("Col1"))
assert.Equal(t, "B2", secondRow.GetData("Col2"))
assert.Equal(t, "C2", secondRow.GetData("Col3"))
assert.False(t, iterator.HasNext())
assert.Nil(t, iterator.Next())
}
@@ -0,0 +1,220 @@
package datatable
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/errs"
)
type testTransactionDataRowParser struct {
}
func (p *testTransactionDataRowParser) Parse(rowData map[TransactionDataTableColumn]string) (map[TransactionDataTableColumn]string, bool, error) {
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
return rowData, true, nil
}
func (p *testTransactionDataRowParser) GetAddedColumns() []TransactionDataTableColumn {
return []TransactionDataTableColumn{TRANSACTION_DATA_TABLE_DESCRIPTION}
}
func TestBasicDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: []*testBasicDataTableRow{},
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1", "100"},
},
{
rowId: "2",
rowColumns: []string{"2024-01-02", "2", "200"},
},
{
rowId: "3",
rowColumns: []string{"2024-01-03", "1", "300"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1", "100"},
},
{
rowId: "2",
rowColumns: []string{"2024-01-02", "2", "200"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
firstRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, firstRow)
assert.True(t, firstRow.IsValid())
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.True(t, iterator.HasNext())
secondRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, secondRow)
assert.True(t, secondRow.IsValid())
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, iterator.HasNext())
emptyRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.Nil(t, emptyRow)
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_EmptyRow(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{""},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, row)
assert.False(t, row.IsValid())
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_InvalidRow(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.NotNil(t, err)
assert.Equal(t, errs.ErrFewerFieldsInDataRowThanInHeaderRow, err)
assert.Nil(t, row)
}
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_WithRowParserAddedColumn(t *testing.T) {
columns := []string{"TransactionTime", "TransactionType", "Amount"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"2024-01-01", "1", "100"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
columnMapping := map[TransactionDataTableColumn]string{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
TRANSACTION_DATA_TABLE_DESCRIPTION: "Description",
}
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTableWithRowParser(basicDataTable, columnMapping, &testTransactionDataRowParser{})
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, row)
assert.True(t, row.IsValid())
assert.Equal(t, "Test Description", row.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
}
@@ -0,0 +1,258 @@
package datatable
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"
)
type testCommonDataTable struct {
headerColumns []string
dataRows []*testCommonDataTableRow
}
type testCommonDataTableRow struct {
rowId string
rowData map[string]string
}
type testCommonDataTableRowIterator struct {
dataTable *testCommonDataTable
currentIndex int
}
func (t *testCommonDataTable) DataRowCount() int {
return len(t.dataRows)
}
func (t *testCommonDataTable) HeaderColumnCount() int {
return len(t.headerColumns)
}
func (t *testCommonDataTable) HasColumn(columnName string) bool {
for _, header := range t.headerColumns {
if header == columnName {
return true
}
}
return false
}
func (t *testCommonDataTable) DataRowIterator() CommonDataTableRowIterator {
return &testCommonDataTableRowIterator{
dataTable: t,
currentIndex: -1,
}
}
func (t *testCommonDataTableRow) GetData(dataKey string) string {
return t.rowData[dataKey]
}
func (t *testCommonDataTableRow) HasData(dataKey string) bool {
_, exists := t.rowData[dataKey]
return exists
}
func (t *testCommonDataTableRow) ColumnCount() int {
return len(t.rowData)
}
func (t *testCommonDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < len(t.dataTable.dataRows)
}
func (t *testCommonDataTableRowIterator) Next() CommonDataTableRow {
if !t.HasNext() {
return nil
}
t.currentIndex++
return t.dataTable.dataRows[t.currentIndex]
}
func (t *testCommonDataTableRowIterator) CurrentRowId() string {
if t.currentIndex < 0 || t.currentIndex >= len(t.dataTable.dataRows) {
return ""
}
return t.dataTable.dataRows[t.currentIndex].rowId
}
type testCommonTransactionDataRowParser struct {
returnError bool
}
func (p *testCommonTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow CommonDataTableRow, rowId string) (map[TransactionDataTableColumn]string, bool, error) {
if p.returnError {
return nil, false, errs.ErrOperationFailed
}
rowData := make(map[TransactionDataTableColumn]string)
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData("TransactionTime")
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData("TransactionType")
rowData[TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData("Amount")
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
return rowData, true, nil
}
func TestCommonDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: []*testCommonDataTableRow{},
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
}
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
rows := []*testCommonDataTableRow{
{
rowId: "1",
rowData: map[string]string{
"TransactionTime": "2024-01-01",
"TransactionType": "1",
"Amount": "100",
},
},
{
rowId: "2",
rowData: map[string]string{
"TransactionTime": "2024-01-02",
"TransactionType": "2",
"Amount": "200",
},
},
{
rowId: "3",
rowData: map[string]string{
"TransactionTime": "2024-01-03",
"TransactionType": "1",
"Amount": "300",
},
},
}
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: rows,
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
}
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
rows := []*testCommonDataTableRow{
{
rowId: "1",
rowData: map[string]string{
"TransactionTime": "2024-01-01",
"TransactionType": "1",
"Amount": "100",
},
},
{
rowId: "2",
rowData: map[string]string{
"TransactionTime": "2024-01-02",
"TransactionType": "2",
"Amount": "200",
},
},
}
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: rows,
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
firstRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, firstRow)
assert.True(t, firstRow.IsValid())
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.Equal(t, "", firstRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.True(t, iterator.HasNext())
secondRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.NotNil(t, secondRow)
assert.True(t, secondRow.IsValid())
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
assert.Equal(t, "", secondRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
assert.False(t, iterator.HasNext())
emptyRow, err := iterator.Next(nil, nil)
assert.Nil(t, err)
assert.Nil(t, emptyRow)
}
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator_EOF(t *testing.T) {
rows := []*testCommonDataTableRow{
{
rowId: "1",
rowData: map[string]string{
"TransactionTime": "2024-01-01",
"TransactionType": "1",
"Amount": "100",
},
},
}
basicDataTable := &testCommonDataTable{
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
dataRows: rows,
}
supportedColumns := map[TransactionDataTableColumn]bool{
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
TRANSACTION_DATA_TABLE_AMOUNT: true,
}
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{returnError: true})
iterator := transactionDataTable.TransactionRowIterator()
assert.True(t, iterator.HasNext())
row, err := iterator.Next(nil, nil)
assert.EqualError(t, err, errs.ErrOperationFailed.Message)
assert.Nil(t, row)
}
@@ -0,0 +1,87 @@
package datatable
// SubBasicDataTable defines the structure of sub basic data table
type SubBasicDataTable struct {
baseTable BasicDataTable
fromIndex int
toIndex int
}
// SubBasicDataTableRowIterator defines the structure of sub basic data table row iterator
type SubBasicDataTableRowIterator struct {
dataTable *SubBasicDataTable
innerIterator BasicDataTableRowIterator
currentIndex int
}
// DataRowCount returns the total count of data row
func (t *SubBasicDataTable) DataRowCount() int {
return t.toIndex - t.fromIndex
}
// HeaderColumnNames returns the header column name list
func (t *SubBasicDataTable) HeaderColumnNames() []string {
return t.baseTable.HeaderColumnNames()
}
// DataRowIterator returns the iterator of data row
func (t *SubBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
innerIterator := t.baseTable.DataRowIterator()
currentIndex := -1
// skip rows until reaching the fromIndex
for currentIndex = -1; currentIndex < t.fromIndex-1 && innerIterator.HasNext(); currentIndex++ {
innerIterator.Next()
}
return &SubBasicDataTableRowIterator{
dataTable: t,
innerIterator: innerIterator,
currentIndex: currentIndex,
}
}
// HasNext returns whether the iterator does not reach the end
func (t *SubBasicDataTableRowIterator) HasNext() bool {
return t.currentIndex+1 < t.dataTable.toIndex && t.innerIterator.HasNext()
}
// CurrentRowId returns current row id
func (t *SubBasicDataTableRowIterator) CurrentRowId() string {
return t.innerIterator.CurrentRowId()
}
// Next returns the next basic data row
func (t *SubBasicDataTableRowIterator) Next() BasicDataTableRow {
if t.currentIndex+1 >= t.dataTable.toIndex {
return nil
}
t.currentIndex++
return t.innerIterator.Next()
}
// CreateSubBasicTable returns a sub basic data table that references a portion of the original table
func CreateSubBasicTable(dataTable BasicDataTable, fromIndex, toIndex int) *SubBasicDataTable {
if fromIndex < 0 {
fromIndex = 0
}
if fromIndex > dataTable.DataRowCount() {
fromIndex = dataTable.DataRowCount()
}
if toIndex > dataTable.DataRowCount() {
toIndex = dataTable.DataRowCount()
}
if toIndex < fromIndex {
toIndex = fromIndex
}
return &SubBasicDataTable{
baseTable: dataTable,
fromIndex: fromIndex,
toIndex: toIndex,
}
}
@@ -0,0 +1,140 @@
package datatable
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateSubBasicTable_WithValidInput(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
{
rowId: "3",
rowColumns: []string{"A3", "B3", "C3"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, 1, 2)
assert.Equal(t, 1, subTable.DataRowCount())
assert.Equal(t, columns, subTable.HeaderColumnNames())
}
func TestCreateSubBasicTable_WithInvalidInput(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, -1, 2)
assert.Equal(t, 0, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
subTable = CreateSubBasicTable(basicDataTable, 5, 2)
assert.Equal(t, 2, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
subTable = CreateSubBasicTable(basicDataTable, 0, 5)
assert.Equal(t, 0, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
subTable = CreateSubBasicTable(basicDataTable, 2, 1)
assert.Equal(t, 2, subTable.fromIndex)
assert.Equal(t, 2, subTable.toIndex)
}
func TestSubBasicDataTable_DataRowIterator(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
{
rowId: "3",
rowColumns: []string{"A3", "B3", "C3"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, 1, 3)
iterator := subTable.DataRowIterator()
assert.True(t, iterator.HasNext())
firstRow := iterator.Next()
assert.NotNil(t, firstRow)
assert.Equal(t, "2", iterator.CurrentRowId())
assert.Equal(t, "A2", firstRow.GetData(0))
assert.Equal(t, "B2", firstRow.GetData(1))
assert.Equal(t, "C2", firstRow.GetData(2))
assert.True(t, iterator.HasNext())
secondRow := iterator.Next()
assert.NotNil(t, secondRow)
assert.Equal(t, "3", iterator.CurrentRowId())
assert.Equal(t, "A3", secondRow.GetData(0))
assert.Equal(t, "B3", secondRow.GetData(1))
assert.Equal(t, "C3", secondRow.GetData(2))
assert.False(t, iterator.HasNext())
assert.Nil(t, iterator.Next())
}
func TestSubBasicDataTable_EmptyDataRange(t *testing.T) {
columns := []string{"Col1", "Col2", "Col3"}
rows := []*testBasicDataTableRow{
{
rowId: "1",
rowColumns: []string{"A1", "B1", "C1"},
},
{
rowId: "2",
rowColumns: []string{"A2", "B2", "C2"},
},
}
basicDataTable := &testBasicDataTable{
headerColumns: columns,
rows: rows,
}
subTable := CreateSubBasicTable(basicDataTable, 1, 1)
assert.Equal(t, 0, subTable.DataRowCount())
iterator := subTable.DataRowIterator()
assert.False(t, iterator.HasNext())
assert.Nil(t, iterator.Next())
}
@@ -0,0 +1,96 @@
package _default
import (
"encoding/json"
"time"
"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/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var allJsonDataSupportedColumns = []datatable.TransactionDataTableColumn{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE,
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY,
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME,
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT,
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION,
datatable.TRANSACTION_DATA_TABLE_TAGS,
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION,
}
// defaultTransactionDataJsonImporter defines the structure of ezbookkeeping default json importer for transaction data
type defaultTransactionDataJsonImporter struct{}
// Initialize an ezbookkeeping default transaction data json file importer singleton instance
var (
DefaultTransactionDataJsonFileImporter = &defaultTransactionDataJsonImporter{}
)
// ParseImportedData returns the imported data by parsing the transaction json data
func (c *defaultTransactionDataJsonImporter) 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) {
var importRequest models.ImportTransactionRequest
if err := json.Unmarshal(data, &importRequest); err != nil {
return nil, nil, nil, nil, nil, nil, errs.ErrInvalidJSONFile
}
transactionDataTable, err := c.createNewDefaultTransactionDataTable(importRequest)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(
ezbookkeepingTransactionTypeNameMapping,
ezbookkeepingGeoLocationSeparator,
ezbookkeepingGeoLocationOrder,
ezbookkeepingTagSeparator,
)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
func (c *defaultTransactionDataJsonImporter) createNewDefaultTransactionDataTable(importRequest models.ImportTransactionRequest) (datatable.TransactionDataTable, error) {
transactionDataTable := datatable.CreateNewWritableTransactionDataTable(allJsonDataSupportedColumns)
if importRequest.Transactions == nil || len(importRequest.Transactions) < 1 {
return nil, errs.ErrNotFoundTransactionDataInFile
}
for i := 0; i < len(importRequest.Transactions); i++ {
transaction := importRequest.Transactions[i]
utcOffset, err := utils.StringToInt(transaction.UtcOffset)
if err != nil {
return nil, errs.ErrTransactionTimeZoneInvalid
}
timezone := time.FixedZone("Transaction Timezone", utcOffset*60)
row := make(map[datatable.TransactionDataTableColumn]string, len(allJsonDataSupportedColumns))
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = transaction.Time
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(timezone)
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = transaction.Type
row[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = transaction.CategoryName
row[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = transaction.SourceAccountName
row[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = transaction.SourceAmount
row[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = transaction.DestinationAccountName
row[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = transaction.DestinationAmount
row[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = transaction.GeoLocation
row[datatable.TRANSACTION_DATA_TABLE_TAGS] = transaction.TagNames
row[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transaction.Comment
transactionDataTable.Add(row)
}
return transactionDataTable, nil
}
@@ -153,11 +153,7 @@ func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Contex
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
} }
if !c.hasHeaderLine { dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines, c.hasHeaderLine)
allLines = append([][]string{{}}, allLines...)
}
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines)
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol) transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator) dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
@@ -14,6 +14,7 @@ import (
type ExcelMSCFBFileBasicDataTable struct { type ExcelMSCFBFileBasicDataTable struct {
workbook *xls.WorkBook workbook *xls.WorkBook
headerLineColumnNames []string headerLineColumnNames []string
hasTitleLine bool
} }
// ExcelMSCFBFileBasicDataTableRow defines the structure of excel (microsoft compound file binary) file data table row // ExcelMSCFBFileBasicDataTableRow defines the structure of excel (microsoft compound file binary) file data table row
@@ -26,7 +27,7 @@ type ExcelMSCFBFileBasicDataTableRow struct {
type ExcelMSCFBFileBasicDataTableRowIterator struct { type ExcelMSCFBFileBasicDataTableRowIterator struct {
dataTable *ExcelMSCFBFileBasicDataTable dataTable *ExcelMSCFBFileBasicDataTable
currentSheetIndex int currentSheetIndex int
currentRowIndexInSheet uint16 currentRowIndexInSheet int
} }
// DataRowCount returns the total count of data row // DataRowCount returns the total count of data row
@@ -36,11 +37,23 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowCount() int {
for i := 0; i < t.workbook.NumSheets(); i++ { for i := 0; i < t.workbook.NumSheets(); i++ {
sheet := t.workbook.GetSheet(i) sheet := t.workbook.GetSheet(i)
if sheet.MaxRow < 1 { if sheet == nil {
continue continue
} }
totalDataRowCount += int(sheet.MaxRow) if t.hasTitleLine {
if sheet.MaxRow < 1 {
continue
}
totalDataRowCount += int(sheet.MaxRow)
} else {
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
continue
}
totalDataRowCount += int(sheet.MaxRow) + 1
}
} }
return totalDataRowCount return totalDataRowCount
@@ -48,15 +61,25 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowCount() int {
// HeaderColumnNames returns the header column name list // HeaderColumnNames returns the header column name list
func (t *ExcelMSCFBFileBasicDataTable) HeaderColumnNames() []string { func (t *ExcelMSCFBFileBasicDataTable) HeaderColumnNames() []string {
if !t.hasTitleLine {
return nil
}
return t.headerLineColumnNames return t.headerLineColumnNames
} }
// DataRowIterator returns the iterator of data row // DataRowIterator returns the iterator of data row
func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator { func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
startIndex := -1
if t.hasTitleLine {
startIndex = 0
}
return &ExcelMSCFBFileBasicDataTableRowIterator{ return &ExcelMSCFBFileBasicDataTableRowIterator{
dataTable: t, dataTable: t,
currentSheetIndex: 0, currentSheetIndex: 0,
currentRowIndexInSheet: 0, currentRowIndexInSheet: startIndex,
} }
} }
@@ -82,15 +105,21 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) HasNext() bool {
currentSheet := workbook.GetSheet(t.currentSheetIndex) currentSheet := workbook.GetSheet(t.currentSheetIndex)
if t.currentRowIndexInSheet+1 <= currentSheet.MaxRow { if t.currentRowIndexInSheet+1 <= int(currentSheet.MaxRow) && currentSheet.Row(t.currentRowIndexInSheet+1) != nil {
return true return true
} }
for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ { for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i) sheet := workbook.GetSheet(i)
if sheet.MaxRow < 1 { if t.dataTable.hasTitleLine {
continue if sheet.MaxRow < 1 {
continue
}
} else {
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
continue
}
} }
return true return true
@@ -107,20 +136,22 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) CurrentRowId() string {
// Next returns the next basic data row // Next returns the next basic data row
func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow { func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
workbook := t.dataTable.workbook workbook := t.dataTable.workbook
currentRowIndexInTable := t.currentRowIndexInSheet
for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ { for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i) sheet := workbook.GetSheet(i)
if currentRowIndexInTable+1 <= sheet.MaxRow { if t.currentRowIndexInSheet+1 <= int(sheet.MaxRow) && sheet.Row(t.currentRowIndexInSheet+1) != nil {
t.currentRowIndexInSheet++ t.currentRowIndexInSheet++
currentRowIndexInTable = t.currentRowIndexInSheet
break break
} }
t.currentSheetIndex++ t.currentSheetIndex++
t.currentRowIndexInSheet = 0
currentRowIndexInTable = 0 if t.dataTable.hasTitleLine {
t.currentRowIndexInSheet = 0
} else {
t.currentRowIndexInSheet = -1
}
} }
if t.currentSheetIndex >= workbook.NumSheets() { if t.currentSheetIndex >= workbook.NumSheets() {
@@ -129,7 +160,7 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
currentSheet := workbook.GetSheet(t.currentSheetIndex) currentSheet := workbook.GetSheet(t.currentSheetIndex)
if t.currentRowIndexInSheet > currentSheet.MaxRow { if t.currentRowIndexInSheet > int(currentSheet.MaxRow) || currentSheet.Row(t.currentRowIndexInSheet) == nil {
return nil return nil
} }
@@ -140,7 +171,7 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
} }
// CreateNewExcelMSCFBFileBasicDataTable returns excel (microsoft compound file binary) data table by file binary data // CreateNewExcelMSCFBFileBasicDataTable returns excel (microsoft compound file binary) data table by file binary data
func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTable, error) { func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (datatable.BasicDataTable, error) {
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
workbook, err := xls.OpenReader(reader, "") workbook, err := xls.OpenReader(reader, "")
@@ -148,12 +179,12 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTabl
return nil, err return nil, err
} }
var headerRowItems []string var firstRowItems []string
for i := 0; i < workbook.NumSheets(); i++ { for i := 0; i < workbook.NumSheets(); i++ {
sheet := workbook.GetSheet(i) sheet := workbook.GetSheet(i)
if sheet.MaxRow < 0 { if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
continue continue
} }
@@ -171,21 +202,28 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTabl
break break
} }
headerRowItems = append(headerRowItems, headerItem) firstRowItems = append(firstRowItems, headerItem)
} }
} else { } else {
for j := 0; j <= min(row.LastCol(), len(headerRowItems)-1); j++ { for j := 0; j <= min(row.LastCol(), len(firstRowItems)-1); j++ {
headerItem := row.Col(j) headerItem := row.Col(j)
if headerItem != headerRowItems[j] { if headerItem != firstRowItems[j] {
return nil, errs.ErrFieldsInMultiTableAreDifferent return nil, errs.ErrFieldsInMultiTableAreDifferent
} }
} }
} }
} }
var headerLineColumnNames []string = nil
if hasTitleLine {
headerLineColumnNames = firstRowItems
}
return &ExcelMSCFBFileBasicDataTable{ return &ExcelMSCFBFileBasicDataTable{
workbook: workbook, workbook: workbook,
headerLineColumnNames: headerRowItems, headerLineColumnNames: headerLineColumnNames,
hasTitleLine: hasTitleLine,
}, nil }, nil
} }
@@ -13,7 +13,16 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Equal(t, 3, datatable.DataRowCount())
}
func TestExcelMSCFBFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount()) assert.Equal(t, 2, datatable.DataRowCount())
} }
@@ -22,7 +31,16 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Equal(t, 9, datatable.DataRowCount())
}
func TestExcelMSCFBFileBasicDataTableDataRowCount_MultipleSheets_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 5, datatable.DataRowCount()) assert.Equal(t, 5, datatable.DataRowCount())
} }
@@ -31,7 +49,7 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount()) assert.Equal(t, 0, datatable.DataRowCount())
} }
@@ -40,7 +58,11 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount()) assert.Equal(t, 0, datatable.DataRowCount())
} }
@@ -49,7 +71,17 @@ func TestExcelMSCFBFileBasicDataTableHeaderColumnNames(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestExcelMSCFBFileBasicDataTableHeaderColumnNames_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames()) assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
} }
@@ -57,15 +89,47 @@ func TestExcelMSCFBFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Nil(t, datatable.HeaderColumnNames())
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
assert.Nil(t, datatable.HeaderColumnNames()) assert.Nil(t, datatable.HeaderColumnNames())
} }
func TestExcelMSCFBFileBasicDataTableRowIterator(t *testing.T) { func TestExcelMSCFBFileBasicDataRowIterator(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
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.True(t, iterator.HasNext())
// data row 3
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelMSCFBFileBasicDataRowIterator_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext()) assert.True(t, iterator.HasNext())
@@ -86,11 +150,66 @@ func TestExcelMSCFBFileBasicDataTableRowIterator(t *testing.T) {
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
} }
func TestExcelMSCFBFileBasicDataTableRowIterator_MultipleSheets(t *testing.T) { func TestExcelMSCFBFileBasicDataRowIterator_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
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 1 data row 3
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 3 data row 2
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 4 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.True(t, iterator.HasNext())
// sheet 5 data row 3
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 TestExcelMSCFBFileBasicDataRowIterator_MultipleSheets_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext()) assert.True(t, iterator.HasNext())
@@ -123,11 +242,12 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_MultipleSheets(t *testing.T) {
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
} }
func TestExcelMSCFBFileBasicDataTableRowIterator_OnlyHeaderLine(t *testing.T) { func TestExcelMSCFBFileBasicDataRowIterator_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
@@ -140,11 +260,12 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_OnlyHeaderLine(t *testing.T) {
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
} }
func TestExcelMSCFBFileBasicDataTableRowIterator_EmptyContent(t *testing.T) { func TestExcelMSCFBFileBasicDataRowIterator_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
@@ -155,13 +276,27 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_EmptyContent(t *testing.T) {
// not existed data row 2 // not existed data row 2
assert.Nil(t, iterator.Next()) assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
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 TestExcelMSCFBFileBasicDataTableRowColumnCount(t *testing.T) { func TestExcelMSCFBFileBasicDataRowColumnCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
row1 := iterator.Next() row1 := iterator.Next()
@@ -171,11 +306,36 @@ func TestExcelMSCFBFileBasicDataTableRowColumnCount(t *testing.T) {
assert.EqualValues(t, 4, row2.ColumnCount()) assert.EqualValues(t, 4, row2.ColumnCount())
} }
func TestExcelMSCFBFileBasicDataTableRowGetData(t *testing.T) { func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A1", row1.GetData(0))
assert.Equal(t, "B1", row1.GetData(1))
assert.Equal(t, "C1", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A2", row2.GetData(0))
assert.Equal(t, "B2", row2.GetData(1))
assert.Equal(t, "C2", row2.GetData(2))
row3 := iterator.Next()
assert.Equal(t, "A3", row3.GetData(0))
assert.Equal(t, "B3", row3.GetData(1))
assert.Equal(t, "C3", row3.GetData(2))
}
func TestExcelMSCFBFileBasicDataRowGetData_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
row1 := iterator.Next() row1 := iterator.Next()
@@ -189,22 +349,80 @@ func TestExcelMSCFBFileBasicDataTableRowGetData(t *testing.T) {
assert.Equal(t, "C3", row2.GetData(2)) assert.Equal(t, "C3", row2.GetData(2))
} }
func TestExcelMSCFBFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) { func TestExcelMSCFBFileBasicDataRowGetData_GetNotExistedColumnData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
row1 := iterator.Next() row1 := iterator.Next()
assert.Equal(t, "", row1.GetData(3)) assert.Equal(t, "", row1.GetData(3))
} }
func TestExcelMSCFBFileBasicDataTableRowGetData_MultipleSheets(t *testing.T) { func TestExcelMSCFBFileBasicDataRowGetData_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata) datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next()
assert.Equal(t, "A1", sheet1Row1.GetData(0))
assert.Equal(t, "B1", sheet1Row1.GetData(1))
assert.Equal(t, "C1", sheet1Row1.GetData(2))
sheet1Row2 := iterator.Next()
assert.Equal(t, "1-A2", sheet1Row2.GetData(0))
assert.Equal(t, "1-B2", sheet1Row2.GetData(1))
assert.Equal(t, "1-C2", sheet1Row2.GetData(2))
sheet1Row3 := iterator.Next()
assert.Equal(t, "1-A3", sheet1Row3.GetData(0))
assert.Equal(t, "1-B3", sheet1Row3.GetData(1))
assert.Equal(t, "1-C3", sheet1Row3.GetData(2))
// skip empty sheet2
sheet3Row1 := iterator.Next()
assert.Equal(t, "A1", sheet3Row1.GetData(0))
assert.Equal(t, "B1", sheet3Row1.GetData(1))
assert.Equal(t, "C1", sheet3Row1.GetData(2))
sheet3Row2 := iterator.Next()
assert.Equal(t, "3-A2", sheet3Row2.GetData(0))
assert.Equal(t, "3-B2", sheet3Row2.GetData(1))
assert.Equal(t, "", sheet3Row2.GetData(2))
sheet4Row1 := iterator.Next()
assert.Equal(t, "A1", sheet4Row1.GetData(0))
assert.Equal(t, "B1", sheet4Row1.GetData(1))
assert.Equal(t, "C1", sheet4Row1.GetData(2))
sheet5Row1 := iterator.Next()
assert.Equal(t, "A1", sheet5Row1.GetData(0))
assert.Equal(t, "B1", sheet5Row1.GetData(1))
assert.Equal(t, "C1", sheet5Row1.GetData(2))
sheet5Row2 := iterator.Next()
assert.Equal(t, "5-A2", sheet5Row2.GetData(0))
assert.Equal(t, "5-B2", sheet5Row2.GetData(1))
assert.Equal(t, "5-C2", sheet5Row2.GetData(2))
sheet5Row3 := iterator.Next()
assert.Equal(t, "5-A3", sheet5Row3.GetData(0))
assert.Equal(t, "5-B3", sheet5Row3.GetData(1))
assert.Equal(t, "5-C3", sheet5Row3.GetData(2))
}
func TestExcelMSCFBFileBasicDataRowGetData_MultipleSheets_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
assert.Nil(t, err)
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next() sheet1Row1 := iterator.Next()
@@ -241,6 +459,6 @@ func TestCreateNewExcelMSCFBFileBasicDataTable_MultipleSheetsWithDifferentHeader
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
assert.Nil(t, err) assert.Nil(t, err)
_, err = CreateNewExcelMSCFBFileBasicDataTable(testdata) _, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message) assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
} }
@@ -20,6 +20,7 @@ type excelOOXMLSheet struct {
type ExcelOOXMLFileBasicDataTable struct { type ExcelOOXMLFileBasicDataTable struct {
sheets []*excelOOXMLSheet sheets []*excelOOXMLSheet
headerLineColumnNames []string headerLineColumnNames []string
hasTitleLine bool
} }
// ExcelOOXMLFileBasicDataTableRow defines the structure of excel (Office Open XML) file data table row // ExcelOOXMLFileBasicDataTableRow defines the structure of excel (Office Open XML) file data table row
@@ -47,7 +48,11 @@ func (t *ExcelOOXMLFileBasicDataTable) DataRowCount() int {
continue continue
} }
totalDataRowCount += len(sheet.allData) - 1 if t.hasTitleLine {
totalDataRowCount += len(sheet.allData) - 1
} else {
totalDataRowCount += len(sheet.allData)
}
} }
return totalDataRowCount return totalDataRowCount
@@ -55,15 +60,25 @@ func (t *ExcelOOXMLFileBasicDataTable) DataRowCount() int {
// HeaderColumnNames returns the header column name list // HeaderColumnNames returns the header column name list
func (t *ExcelOOXMLFileBasicDataTable) HeaderColumnNames() []string { func (t *ExcelOOXMLFileBasicDataTable) HeaderColumnNames() []string {
if !t.hasTitleLine {
return nil
}
return t.headerLineColumnNames return t.headerLineColumnNames
} }
// DataRowIterator returns the iterator of data row // DataRowIterator returns the iterator of data row
func (t *ExcelOOXMLFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator { func (t *ExcelOOXMLFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
startIndex := -1
if t.hasTitleLine {
startIndex = 0
}
return &ExcelOOXMLFileBasicDataTableRowIterator{ return &ExcelOOXMLFileBasicDataTableRowIterator{
dataTable: t, dataTable: t,
currentSheetIndex: 0, currentSheetIndex: 0,
currentRowIndexInSheet: 0, currentRowIndexInSheet: startIndex,
} }
} }
@@ -98,8 +113,14 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) HasNext() bool {
for i := t.currentSheetIndex + 1; i < len(sheets); i++ { for i := t.currentSheetIndex + 1; i < len(sheets); i++ {
sheet := sheets[i] sheet := sheets[i]
if len(sheet.allData) <= 1 { if t.dataTable.hasTitleLine {
continue if len(sheet.allData) <= 1 {
continue
}
} else {
if len(sheet.allData) <= 0 {
continue
}
} }
return true return true
@@ -116,20 +137,22 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) CurrentRowId() string {
// Next returns the next basic data row // Next returns the next basic data row
func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow { func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
sheets := t.dataTable.sheets sheets := t.dataTable.sheets
currentRowIndexInTable := t.currentRowIndexInSheet
for i := t.currentSheetIndex; i < len(sheets); i++ { for i := t.currentSheetIndex; i < len(sheets); i++ {
sheet := sheets[i] sheet := sheets[i]
if currentRowIndexInTable+1 < len(sheet.allData) { if t.currentRowIndexInSheet+1 < len(sheet.allData) {
t.currentRowIndexInSheet++ t.currentRowIndexInSheet++
currentRowIndexInTable = t.currentRowIndexInSheet
break break
} }
t.currentSheetIndex++ t.currentSheetIndex++
t.currentRowIndexInSheet = 0
currentRowIndexInTable = 0 if t.dataTable.hasTitleLine {
t.currentRowIndexInSheet = 0
} else {
t.currentRowIndexInSheet = -1
}
} }
if t.currentSheetIndex >= len(sheets) { if t.currentSheetIndex >= len(sheets) {
@@ -150,7 +173,7 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
} }
// CreateNewExcelOOXMLFileBasicDataTable returns excel (Office Open XML) data table by file binary data // CreateNewExcelOOXMLFileBasicDataTable returns excel (Office Open XML) data table by file binary data
func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTable, error) { func CreateNewExcelOOXMLFileBasicDataTable(data []byte, hasTitleLine bool) (datatable.BasicDataTable, error) {
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
file, err := excelize.OpenReader(reader) file, err := excelize.OpenReader(reader)
@@ -161,7 +184,7 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
} }
sheetNames := file.GetSheetList() sheetNames := file.GetSheetList()
var headerRowItems []string var firstRowItems []string
var sheets []*excelOOXMLSheet var sheets []*excelOOXMLSheet
for i := 0; i < len(sheetNames); i++ { for i := 0; i < len(sheetNames); i++ {
@@ -186,13 +209,13 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
break break
} }
headerRowItems = append(headerRowItems, headerItem) firstRowItems = append(firstRowItems, headerItem)
} }
} else { } else {
for j := 0; j < min(len(row), len(headerRowItems)); j++ { for j := 0; j < min(len(row), len(firstRowItems)); j++ {
headerItem := row[j] headerItem := row[j]
if headerItem != headerRowItems[j] { if headerItem != firstRowItems[j] {
return nil, errs.ErrFieldsInMultiTableAreDifferent return nil, errs.ErrFieldsInMultiTableAreDifferent
} }
} }
@@ -204,8 +227,15 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
}) })
} }
var headerLineColumnNames []string = nil
if hasTitleLine {
headerLineColumnNames = firstRowItems
}
return &ExcelOOXMLFileBasicDataTable{ return &ExcelOOXMLFileBasicDataTable{
sheets: sheets, sheets: sheets,
headerLineColumnNames: headerRowItems, headerLineColumnNames: headerLineColumnNames,
hasTitleLine: hasTitleLine,
}, nil }, nil
} }
@@ -13,7 +13,16 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Equal(t, 3, datatable.DataRowCount())
}
func TestExcelOOXMLFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 2, datatable.DataRowCount()) assert.Equal(t, 2, datatable.DataRowCount())
} }
@@ -22,7 +31,16 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Equal(t, 9, datatable.DataRowCount())
}
func TestExcelOOXMLFileBasicDataTableDataRowCount_MultipleSheets_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 5, datatable.DataRowCount()) assert.Equal(t, 5, datatable.DataRowCount())
} }
@@ -31,7 +49,7 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount()) assert.Equal(t, 0, datatable.DataRowCount())
} }
@@ -40,7 +58,11 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount())
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 0, datatable.DataRowCount()) assert.Equal(t, 0, datatable.DataRowCount())
} }
@@ -49,7 +71,17 @@ func TestExcelOOXMLFileBasicDataTableHeaderColumnNames(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Nil(t, datatable.HeaderColumnNames())
}
func TestExcelOOXMLFileBasicDataTableHeaderColumnNames_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames()) assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
} }
@@ -57,7 +89,12 @@ func TestExcelOOXMLFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
assert.Nil(t, datatable.HeaderColumnNames())
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
assert.Nil(t, datatable.HeaderColumnNames()) assert.Nil(t, datatable.HeaderColumnNames())
} }
@@ -65,7 +102,34 @@ func TestExcelOOXMLFileBasicDataRowIterator(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
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.True(t, iterator.HasNext())
// data row 3
assert.NotNil(t, iterator.Next())
assert.False(t, iterator.HasNext())
// not existed data row 4
assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext())
}
func TestExcelOOXMLFileBasicDataRowIterator_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext()) assert.True(t, iterator.HasNext())
@@ -90,7 +154,62 @@ func TestExcelOOXMLFileBasicDataRowIterator_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
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 1 data row 3
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 3 data row 2
assert.NotNil(t, iterator.Next())
assert.True(t, iterator.HasNext())
// sheet 4 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.True(t, iterator.HasNext())
// sheet 5 data row 3
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 TestExcelOOXMLFileBasicDataRowIterator_MultipleSheets_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.True(t, iterator.HasNext()) assert.True(t, iterator.HasNext())
@@ -127,7 +246,8 @@ func TestExcelOOXMLFileBasicDataRowIterator_OnlyHeaderLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
@@ -144,7 +264,8 @@ func TestExcelOOXMLFileBasicDataRowIterator_EmptyContent(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
@@ -155,13 +276,27 @@ func TestExcelOOXMLFileBasicDataRowIterator_EmptyContent(t *testing.T) {
// not existed data row 2 // not existed data row 2
assert.Nil(t, iterator.Next()) assert.Nil(t, iterator.Next())
assert.False(t, iterator.HasNext()) assert.False(t, iterator.HasNext())
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
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 TestExcelOOXMLFileBasicDataRowColumnCount(t *testing.T) { func TestExcelOOXMLFileBasicDataRowColumnCount(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
row1 := iterator.Next() row1 := iterator.Next()
@@ -175,7 +310,32 @@ func TestExcelOOXMLFileBasicDataRowGetData(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator()
row1 := iterator.Next()
assert.Equal(t, "A1", row1.GetData(0))
assert.Equal(t, "B1", row1.GetData(1))
assert.Equal(t, "C1", row1.GetData(2))
row2 := iterator.Next()
assert.Equal(t, "A2", row2.GetData(0))
assert.Equal(t, "B2", row2.GetData(1))
assert.Equal(t, "C2", row2.GetData(2))
row3 := iterator.Next()
assert.Equal(t, "A3", row3.GetData(0))
assert.Equal(t, "B3", row3.GetData(1))
assert.Equal(t, "C3", row3.GetData(2))
}
func TestExcelOOXMLFileBasicDataRowGetData_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
row1 := iterator.Next() row1 := iterator.Next()
@@ -193,7 +353,8 @@ func TestExcelOOXMLFileBasicDataRowGetData_GetNotExistedColumnData(t *testing.T)
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
row1 := iterator.Next() row1 := iterator.Next()
@@ -204,7 +365,64 @@ func TestExcelOOXMLFileBasicDataRowGetData_MultipleSheets(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata) datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
assert.Nil(t, err)
iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next()
assert.Equal(t, "A1", sheet1Row1.GetData(0))
assert.Equal(t, "B1", sheet1Row1.GetData(1))
assert.Equal(t, "C1", sheet1Row1.GetData(2))
sheet1Row2 := iterator.Next()
assert.Equal(t, "1-A2", sheet1Row2.GetData(0))
assert.Equal(t, "1-B2", sheet1Row2.GetData(1))
assert.Equal(t, "1-C2", sheet1Row2.GetData(2))
sheet1Row3 := iterator.Next()
assert.Equal(t, "1-A3", sheet1Row3.GetData(0))
assert.Equal(t, "1-B3", sheet1Row3.GetData(1))
assert.Equal(t, "1-C3", sheet1Row3.GetData(2))
// skip empty sheet2
sheet3Row1 := iterator.Next()
assert.Equal(t, "A1", sheet3Row1.GetData(0))
assert.Equal(t, "B1", sheet3Row1.GetData(1))
assert.Equal(t, "C1", sheet3Row1.GetData(2))
sheet3Row2 := iterator.Next()
assert.Equal(t, "3-A2", sheet3Row2.GetData(0))
assert.Equal(t, "3-B2", sheet3Row2.GetData(1))
assert.Equal(t, "", sheet3Row2.GetData(2))
sheet4Row1 := iterator.Next()
assert.Equal(t, "A1", sheet4Row1.GetData(0))
assert.Equal(t, "B1", sheet4Row1.GetData(1))
assert.Equal(t, "C1", sheet4Row1.GetData(2))
sheet5Row1 := iterator.Next()
assert.Equal(t, "A1", sheet5Row1.GetData(0))
assert.Equal(t, "B1", sheet5Row1.GetData(1))
assert.Equal(t, "C1", sheet5Row1.GetData(2))
sheet5Row2 := iterator.Next()
assert.Equal(t, "5-A2", sheet5Row2.GetData(0))
assert.Equal(t, "5-B2", sheet5Row2.GetData(1))
assert.Equal(t, "5-C2", sheet5Row2.GetData(2))
sheet5Row3 := iterator.Next()
assert.Equal(t, "5-A3", sheet5Row3.GetData(0))
assert.Equal(t, "5-B3", sheet5Row3.GetData(1))
assert.Equal(t, "5-C3", sheet5Row3.GetData(2))
}
func TestExcelOOXMLFileBasicDataRowGetData_MultipleSheets_HasTitleLine(t *testing.T) {
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
assert.Nil(t, err)
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.Nil(t, err)
iterator := datatable.DataRowIterator() iterator := datatable.DataRowIterator()
sheet1Row1 := iterator.Next() sheet1Row1 := iterator.Next()
@@ -241,6 +459,6 @@ func TestCreateNewExcelOOXMLFileBasicDataTable_MultipleSheetsWithDifferentHeader
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx") testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
assert.Nil(t, err) assert.Nil(t, err)
_, err = CreateNewExcelOOXMLFileBasicDataTable(testdata) _, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message) assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
} }
@@ -2,14 +2,13 @@ package feidee
import ( import (
"bytes" "bytes"
"encoding/csv"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"io"
"strings" "strings"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter" "github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv" "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable" "github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -60,7 +59,13 @@ func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx c
fallback := unicode.UTF8.NewDecoder() fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback)) reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
dataTable, err := c.createNewFeideeMymoneyAppBasicDataTable(ctx, reader) csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTable, err := createNewFeideeMymoneyAppTransactionBasicDataTable(ctx, csvDataTable)
if err != nil { if err != nil {
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
@@ -89,54 +94,6 @@ func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx c
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
} }
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, 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.CreateNewCustomCsvBasicDataTable(allOriginalLines)
return dataTable, nil
}
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) { func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) {
newColumns := make([]datatable.TransactionDataTableColumn, 0, 11) newColumns := make([]datatable.TransactionDataTableColumn, 0, 11)
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE) newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
@@ -0,0 +1,52 @@
package feidee
import (
"strings"
"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"
)
func createNewFeideeMymoneyAppTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
iterator := originalDataTable.DataRowIterator()
allOriginalLines := make([][]string, 0)
hasFileHeader := false
for iterator.HasNext() {
row := iterator.Next()
if !hasFileHeader {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_extrator.createNewFeideeMymoneyAppTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
continue
}
}
items := make([]string, row.ColumnCount())
for i := 0; i < row.ColumnCount(); i++ {
items[i] = strings.Trim(row.GetData(i), " ")
}
allOriginalLines = append(allOriginalLines, items)
}
if !hasFileHeader {
return nil, errs.ErrInvalidFileHeader
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_extrator.createNewFeideeMymoneyAppTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
}
@@ -32,7 +32,7 @@ var (
// ParseImportedData returns the imported data by parsing the feidee mymoney (elecloud) transaction xlsx data // ParseImportedData returns the imported data by parsing the feidee mymoney (elecloud) transaction xlsx data
func (c *feideeMymoneyElecloudTransactionDataXlsxFileImporter) 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) { func (c *feideeMymoneyElecloudTransactionDataXlsxFileImporter) 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 := excel.CreateNewExcelOOXMLFileBasicDataTable(data) dataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data, true)
if err != nil { if err != nil {
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
@@ -31,7 +31,7 @@ var (
// ParseImportedData returns the imported data by parsing the feidee mymoney (web) transaction xls data // ParseImportedData returns the imported data by parsing the feidee mymoney (web) transaction xls data
func (c *feideeMymoneyWebTransactionDataXlsFileImporter) 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) { func (c *feideeMymoneyWebTransactionDataXlsFileImporter) 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 := excel.CreateNewExcelMSCFBFileBasicDataTable(data) dataTable, err := excel.CreateNewExcelMSCFBFileBasicDataTable(data, true)
if err != nil { if err != nil {
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
@@ -7,24 +7,21 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/converters/csv" "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable" "github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core" "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/models"
) )
var fireflyIIITransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{ var fireflyIIITransactionDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "date",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: true, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "type",
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true, datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "category",
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true, datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "source_name",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true, datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: "currency_code",
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true, datatable.TRANSACTION_DATA_TABLE_AMOUNT: "amount",
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "destination_name",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true, datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "foreign_currency_code",
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true, datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: "foreign_amount",
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true, datatable.TRANSACTION_DATA_TABLE_TAGS: "tags",
datatable.TRANSACTION_DATA_TABLE_TAGS: true, datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "description",
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
} }
var fireflyIIITransactionTypeNameMapping = map[models.TransactionType]string{ var fireflyIIITransactionTypeNameMapping = map[models.TransactionType]string{
@@ -45,27 +42,14 @@ var (
// ParseImportedData returns the imported data by parsing the firefly III transaction csv data // ParseImportedData returns the imported data by parsing the firefly III transaction csv data
func (c *fireflyIIITransactionDataCsvFileImporter) 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) { func (c *fireflyIIITransactionDataCsvFileImporter) 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) {
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
dataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader) dataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, true)
if err != nil { if err != nil {
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
} }
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
if !commonDataTable.HasColumn(fireflyIIITransactionTimeColumnName) ||
!commonDataTable.HasColumn(fireflyIIITransactionTypeColumnName) ||
!commonDataTable.HasColumn(fireflyIIITransactionSourceAccountNameColumnName) ||
!commonDataTable.HasColumn(fireflyIIITransactionSourceAccountTypeColumnName) ||
!commonDataTable.HasColumn(fireflyIIITransactionDestinationAccountNameColumnName) ||
!commonDataTable.HasColumn(fireflyIIITransactionDestinationAccountTypeColumnName) ||
!commonDataTable.HasColumn(fireflyIIITransactionAmountColumnName) {
log.Errorf(ctx, "[fireflyiii_transaction_data_csv_file_importer.ParseImportedData] cannot parse Firefly III csv data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionRowParser := createFireflyIIITransactionDataRowParser() transactionRowParser := createFireflyIIITransactionDataRowParser()
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, fireflyIIITransactionSupportedColumns, transactionRowParser) transactionDataTable := datatable.CreateNewTransactionDataTableFromBasicDataTableWithRowParser(dataTable, fireflyIIITransactionDataColumnNameMapping, transactionRowParser)
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(fireflyIIITransactionTypeNameMapping, "", "", ",") dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(fireflyIIITransactionTypeNameMapping, "", "", ",")
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
@@ -20,11 +20,11 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MinimumValidData(t *testing
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+ "\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Deposit,0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"Test Category\"\n"+ "Deposit,0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Test Account\",\"Test Category\"\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category2\"\n"+ "Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category2\"\n"+
"Transfer,0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil) "Transfer,0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
@@ -91,16 +91,16 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTime(t *testing
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message) assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
} }
func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTransactionType(t *testing.T) { func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext() context := core.NewNullContext()
@@ -109,107 +109,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTransactionType(t
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
// income transactions _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ "Type,123.45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
// expense transactions
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
// opening balance transactions
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"\"Opening balance\",10.00,2024-09-01T12:34:56+08:00,\"Initial balance\",\"Initial balance account\",\"Test Account\",\"Asset account\",\"\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
// transfer transactions
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"Test Account2\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
}
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTransactionType(t *testing.T) {
converter := FireflyIIITransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Revenue account\",\"Test Account2\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message) assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
} }
@@ -222,15 +123,15 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseAccountNameAsCategoryN
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"\""), 0, nil, nil, nil, nil, nil) "Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "A expense account", allNewTransactions[0].OriginalCategoryName) assert.Equal(t, "A expense account", allNewTransactions[0].OriginalCategoryName)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"\""), 0, nil, nil, nil, nil, nil) "Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Test Account\",\"\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
@@ -246,20 +147,20 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTimezone(t *testi
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime)) assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
@@ -274,9 +175,9 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidAccountCurrency(t
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+ "\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Transfer,1.23,-1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil) "Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
@@ -301,8 +202,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"Transfer,10.00,15.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Transfer,10.00,15.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
@@ -312,8 +213,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency) assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency) assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Transfer,10.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
@@ -322,8 +223,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency) assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency) assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil) "Transfer,10.00,2024-09-01T12:34:56+08:00,USD,,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
@@ -340,14 +241,14 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAccountCurrency
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+ "\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil) "Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\n"+ "\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Asset account\",\"Test Account\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil) "Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Test Account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
} }
@@ -360,12 +261,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseNotSupportedCurrency(t
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil) "\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
"Transfer,123.45,123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil) "Transfer,123.45,123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message) assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
} }
@@ -378,12 +279,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAmount(t *testi
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
"Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil) "Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message) assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,destination_name,category\n"+
"Transfer,123.45,123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil) "Transfer,123.45,123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message) assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
} }
@@ -396,8 +297,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseDescription(t *testing
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,destination_name,category\n"+
"Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil) "Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
@@ -413,8 +314,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseTags(t *testing.T) {
DefaultCurrency: "CNY", DefaultCurrency: "CNY",
} }
allNewTransactions, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,tags,date,source_name,source_type,destination_name,destination_type,category\n"+ allNewTransactions, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,tags,date,source_name,destination_name,category\n"+
"Withdrawal,-123.45,\"tag1,tag2,tag3\",2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil) "Withdrawal,-123.45,\"tag1,tag2,tag3\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, 1, len(allNewTransactions)) assert.Equal(t, 1, len(allNewTransactions))
@@ -437,7 +338,7 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingFileHeader(t *testin
} }
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil) _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
} }
func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) { func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
@@ -450,13 +351,18 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *te
} }
// Missing Time Column // Missing Time Column
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,destination_name,category\n"+
"\"Opening balance\",123.45,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil) "\"Opening balance\",123.45,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column // Missing Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,destination_name,category\n"+
"123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil) "123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Sub Category Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name\n"+
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\"\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account Name Column // Missing Account Name Column
@@ -465,22 +371,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *te
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column // Missing Amount Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,source_type,destination_name,destination_type,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,destination_name,category\n"+
"\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil) "\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Account2 Name Column // Missing Account2 Name Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,category\n"+ _, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,category\n"+
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\n"), 0, nil, nil, nil, nil, nil) "\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message) assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Source Account Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,destination_type,category\n"+
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\"Asset account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Destination Account Type Column
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,category\n"+
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Asset account\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
} }
@@ -2,132 +2,103 @@ package fireflyIII
import ( import (
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable" "github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/utils"
) )
const fireflyIIITransactionTimeColumnName = "date"
const fireflyIIITransactionTypeColumnName = "type"
const fireflyIIITransactionCategoryColumnName = "category"
const fireflyIIITransactionSourceAccountNameColumnName = "source_name"
const fireflyIIITransactionSourceAccountTypeColumnName = "source_type"
const fireflyIIITransactionCurrencyCodeColumnName = "currency_code"
const fireflyIIITransactionAmountColumnName = "amount"
const fireflyIIITransactionDestinationAccountNameColumnName = "destination_name"
const fireflyIIITransactionDestinationAccountTypeColumnName = "destination_type"
const fireflyIIITransactionForeignCurrencyCodeColumnName = "foreign_currency_code"
const fireflyIIITransactionForeignAmountColumnName = "foreign_amount"
const fireflyIIITransactionTagsColumnName = "tags"
const fireflyIIITransactionDescriptionColumnName = "description"
const fireflyIIIAssetAccountName = "Asset account"
const fireflyIIIExpenseAccountName = "Expense account"
const fireflyIIIRevenueAccountName = "Revenue account"
const fireflyIIIDebtAccountName = "Debt"
// fireflyIIITransactionDataRowParser defines the structure of firefly III transaction data row parser // fireflyIIITransactionDataRowParser defines the structure of firefly III transaction data row parser
type fireflyIIITransactionDataRowParser struct { type fireflyIIITransactionDataRowParser struct {
} }
// GetAddedColumns returns the added columns after converting the data row
func (p *fireflyIIITransactionDataRowParser) GetAddedColumns() []datatable.TransactionDataTableColumn {
return []datatable.TransactionDataTableColumn{
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
}
}
// Parse returns the converted transaction data row // Parse returns the converted transaction data row
func (p *fireflyIIITransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) { func (p *fireflyIIITransactionDataRowParser) Parse(data map[datatable.TransactionDataTableColumn]string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
rowData = make(map[datatable.TransactionDataTableColumn]string, len(fireflyIIITransactionSupportedColumns)) rowData = make(map[datatable.TransactionDataTableColumn]string, len(data))
for column, value := range data {
rowData[column] = value
}
// use the expense and revenue account name as category names if the category name is empty
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
} else if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
}
}
// parse long date time and timezone // parse long date time and timezone
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(dataRow.GetData(fireflyIIITransactionTimeColumnName)) if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
if err != nil { if err != nil {
return nil, false, errs.ErrTransactionTimeInvalid return nil, false, errs.ErrTransactionTimeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
} }
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location()) // trim trailing zero in decimal
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location()) if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
// parse transaction type, transaction category and amount amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
transactionType := dataRow.GetData(fireflyIIITransactionTypeColumnName)
sourceAccountType := dataRow.GetData(fireflyIIITransactionSourceAccountTypeColumnName)
destinationAccountType := dataRow.GetData(fireflyIIITransactionDestinationAccountTypeColumnName)
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(dataRow.GetData(fireflyIIITransactionAmountColumnName)))
if err != nil {
return nil, false, errs.ErrAmountInvalid
}
foreignAmount := amount
if dataRow.HasData(fireflyIIITransactionForeignAmountColumnName) && dataRow.GetData(fireflyIIITransactionForeignAmountColumnName) != "" {
foreignAmount, err = utils.ParseAmount(utils.TrimTrailingZerosInDecimal(dataRow.GetData(fireflyIIITransactionForeignAmountColumnName)))
if err != nil { if err != nil {
return nil, false, errs.ErrAmountInvalid return nil, false, errs.ErrAmountInvalid
} }
}
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionCategoryColumnName) if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
if sourceAccountType == fireflyIIIRevenueAccountName && (destinationAccountType == fireflyIIIAssetAccountName || destinationAccountType == fireflyIIIDebtAccountName) { // income
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
// if the category is empty, use the source account (revenue account) name as the category name
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
}
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
} else if (sourceAccountType == fireflyIIIAssetAccountName || sourceAccountType == fireflyIIIDebtAccountName) && destinationAccountType == fireflyIIIExpenseAccountName { // expense
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
// if the category is empty, use the destination account (expense account) name as the category name
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
}
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
} else if transactionType == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] { // opening balance
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
} else if (sourceAccountType == fireflyIIIAssetAccountName || sourceAccountType == fireflyIIIDebtAccountName) && (destinationAccountType == fireflyIIIAssetAccountName || destinationAccountType == fireflyIIIDebtAccountName) { // transfer
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
if transactionType == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount) rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-foreignAmount)
} else { } else {
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount) rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(foreignAmount)
} }
} else {
log.Errorf(ctx, "[fireflyiii_transaction_data_row_parser.Parse] cannot detect transaction type, source account type is \"%s\", destination account type is \"%s\", Firefly III transaction type is \"%s\"", sourceAccountType, destinationAccountType, transactionType)
return nil, false, errs.ErrTransactionTypeInvalid
} }
// parse account currency if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = dataRow.GetData(fireflyIIITransactionCurrencyCodeColumnName) rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = dataRow.GetData(fireflyIIITransactionForeignCurrencyCodeColumnName) amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
if err != nil {
return nil, false, errs.ErrAmountInvalid
}
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-amount)
} else {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount)
}
} else {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
}
// the related account currency field is foreign currency in firefly III actually // the related account currency field is foreign currency in firefly III actually
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] != "" { if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
} }
// parse tags / description // the destination account of modify balance transaction in firefly III is the asset account
rowData[datatable.TRANSACTION_DATA_TABLE_TAGS] = dataRow.GetData(fireflyIIITransactionTagsColumnName) if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] {
rowData[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(fireflyIIITransactionDescriptionColumnName) rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
}
// the destination account of income transaction in firefly III is the asset account
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
}
return rowData, true, nil return rowData, true, nil
} }
// createFireflyIIITransactionDataRowParser returns firefly III transaction data row parser // createFireflyIIITransactionDataRowParser returns firefly III transaction data row parser
func createFireflyIIITransactionDataRowParser() datatable.CommonTransactionDataRowParser { func createFireflyIIITransactionDataRowParser() datatable.TransactionDataRowParser {
return &fireflyIIITransactionDataRowParser{} return &fireflyIIITransactionDataRowParser{}
} }
@@ -0,0 +1,64 @@
package jdcom
import (
"bytes"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"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"
)
// jdComFinanceTransactionDataCsvFileImporter defines the structure of jd.com finance csv importer for transaction data
type jdComFinanceTransactionDataCsvFileImporter struct {
fileHeaderLineBeginning string
dataHeaderStartContentBeginning string
}
// Initialize a jd.com finance transaction data csv file importer singleton instance
var (
JDComFinanceTransactionDataCsvFileImporter = &jdComFinanceTransactionDataCsvFileImporter{}
)
// ParseImportedData returns the imported data by parsing the jd.com finance transaction csv data
func (c *jdComFinanceTransactionDataCsvFileImporter) 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))
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTable, err := createNewJDComFinanceTransactionBasicDataTable(ctx, csvDataTable)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
if !commonDataTable.HasColumn(jdComFinanceTransactionTimeColumnName) ||
!commonDataTable.HasColumn(jdComFinanceTransactionMerchantNameColumnName) ||
!commonDataTable.HasColumn(jdComFinanceTransactionMemoColumnName) ||
!commonDataTable.HasColumn(jdComFinanceTransactionAmountColumnName) ||
!commonDataTable.HasColumn(jdComFinanceTransactionRelatedAccountColumnName) ||
!commonDataTable.HasColumn(jdComFinanceTransactionStatusColumnName) ||
!commonDataTable.HasColumn(jdComFinanceTransactionTypeColumnName) {
log.Errorf(ctx, "[jdcom_finance_transaction_data_csv_file_importer.ParseImportedData] cannot parse jd.com finance csv data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionRowParser := createJDComFinanceTransactionDataRowParser(dataTable.HeaderColumnNames())
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, jdComFinanceTransactionSupportedColumns, transactionRowParser)
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(jdComFinanceTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
@@ -0,0 +1,508 @@
package jdcom
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 TestJDComFinanceCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,余额,交易成功,收入,其他\n" +
"2025-09-01 12:34:56,xxx,xxx,123.45,银行卡,交易成功,支出,其他网购\n" +
"2025-09-01 23:59:59,xxx,京东钱包余额充值,0.05,银行卡,交易成功,不计收支,余额\n" +
"2025-09-02 23:59:59,xxx,京东余额提现,0.03,银行卡,交易成功,不计收支,余额\n"
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, 3, 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, "2025-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)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2025-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, "2025-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, "xxx", 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, "2025-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, "xxx", 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, "余额", 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, "xxx", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].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 TestJDComFinanceCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,退款成功,不计收支\n" +
"2025-09-01 02:34:56,xxx,xxx,0.12(已全额退款),银行卡,交易成功,不计收支\n" +
"2025-09-02 01:23:45,xxx,xxx,3.45,银行卡,退款成功,不计收支\n" +
"2025-09-02 02:34:56,xxx,xxx,123.45(已退款3.45),银行卡,交易成功,支出\n"
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, "2025-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, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
assert.Equal(t, "2025-09-01 02:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
assert.Equal(t, "银行卡", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
assert.Equal(t, "2025-09-02 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
assert.Equal(t, int64(-345), allNewTransactions[2].Amount)
assert.Equal(t, "银行卡", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[3].Type)
assert.Equal(t, "2025-09-02 02:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
assert.Equal(t, int64(12345), allNewTransactions[3].Amount)
assert.Equal(t, "银行卡", allNewTransactions[3].OriginalSourceAccountName)
}
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01T01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
data2 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"09/01/2025 01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
}
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,转账\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,¥0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
}
func TestJDComFinanceCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
// transfer to jd.com finance wallet
data1 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,京东钱包余额充值,0.05,银行卡,交易成功,不计收支,余额\n"
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, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
// transfer from jd.com finance wallet
data2 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,京东余额提现,0.05,银行卡,交易成功,不计收支,余额\n"
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, "xxx", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
// transfer from other account
data3 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,京东小金库-转入,0.05,余额,交易成功,不计收支,小金库\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, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
// transfer to other account
data4 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,京东小金库-转出,0.05,余额,交易成功,不计收支,小金库\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, "xxx", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "余额", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
// refund
data5 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,价保退款,0.05,银行卡,交易成功,不计收支,其他\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, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
// repayment
data6 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
"2025-09-01 01:23:45,xxx,白条主动还款,0.05,银行卡,交易成功,不计收支,白条\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, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
}
func TestJDComFinanceCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data1 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,,0.12,银行卡,交易成功,支出\n"
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, "", allNewTransactions[0].Comment)
data2 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,交易说明,金额,收/付款方式,交易状态,收/支,备注\n" +
"2025-09-01 01:23:45,xxx,xxx,Test,0.12,银行卡,交易成功,支出,\"foo\"\"bar,\ntest\"\n"
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "foo\"bar,\ntest", allNewTransactions[0].Comment)
data3 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,交易说明,金额,收/付款方式,交易状态,收/支,备注\n" +
"2025-09-01 01:23:45,xxx,xxx,Test,0.12,银行卡,交易成功,支出,\n"
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
assert.Equal(t, 1, len(allNewTransactions))
assert.Equal(t, "Test", allNewTransactions[0].Comment)
}
func TestJDComFinanceCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,xxxx,支出\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestJDComFinanceCsvFileImporterParseImportedData_SkipUnknownMemoTransferTransaction(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,不计收支\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
func TestJDComFinanceCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
data := "交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, 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 TestJDComFinanceCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1,
DefaultCurrency: "CNY",
}
// Missing Time Column
data1 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"xxx,xxx,0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
// Missing Merchant Name Column
data2 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,交易说明,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Transaction Memo Column
data3 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,金额,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,0.12,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Amount Column
data4 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,收/付款方式,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,银行卡,交易成功,支出\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Related Account Column
data5 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,交易状态,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,交易成功,支出\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Status Column
data6 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,收/支\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,支出\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
// Missing Type Column
data7 := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态\n" +
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功\n"
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data7), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
}
func TestJDComFinanceCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
converter := JDComFinanceTransactionDataCsvFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
data := "导出信息:\n" +
"京东账号名:xxxxxx\n" +
"日期区间:2025-01-01 至 2025-09-01\n" +
"\n" +
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n"
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
}
@@ -0,0 +1,74 @@
package jdcom
import (
"strings"
"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"
)
func createNewJDComFinanceTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
iterator := originalDataTable.DataRowIterator()
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundDataHeaderLine := false
for iterator.HasNext() {
row := iterator.Next()
if !hasFileHeader {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), jdComFinanceTransactionDataCsvFileHeader) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
continue
}
}
if !foundDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
} else if row.GetData(0) == jdComFinanceTransactionTimeColumnName {
foundDataHeaderLine = true
} else {
continue
}
}
if foundDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
}
items := make([]string, row.ColumnCount())
for i := 0; i < row.ColumnCount(); i++ {
items[i] = strings.TrimRight(strings.Trim(row.GetData(i), " "), "\t")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
}
allOriginalLines = append(allOriginalLines, items)
}
}
if !hasFileHeader || !foundDataHeaderLine {
return nil, errs.ErrInvalidFileHeader
}
if len(allOriginalLines) < 2 {
log.Errorf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
}
@@ -0,0 +1,148 @@
package jdcom
import (
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const jdComFinanceTransactionDataCsvFileHeader = "导出信息:"
const jdComFinanceTransactionTimeColumnName = "交易时间"
const jdComFinanceTransactionMerchantNameColumnName = "商户名称"
const jdComFinanceTransactionMemoColumnName = "交易说明"
const jdComFinanceTransactionAmountColumnName = "金额"
const jdComFinanceTransactionRelatedAccountColumnName = "收/付款方式"
const jdComFinanceTransactionStatusColumnName = "交易状态"
const jdComFinanceTransactionTypeColumnName = "收/支"
const jdComFinanceTransactionCategoryColumnName = "交易分类"
const jdComFinanceTransactionDescriptionColumnName = "备注"
const jdComFinanceTransactionAmountRefundAll = "(已全额退款)"
const jdComFinanceTransactionMemoTransferToWalletPrefix = "充值"
const jdComFinanceTransactionMemoTransferFromWalletPrefix = "提现"
const jdComFinanceTransactionMemoTransferInText = "转入"
const jdComFinanceTransactionMemoTransferOutText = "转出"
const jdComFinanceTransactionMemoRepaymentText = "还款"
const jdComFinanceTransactionMemoRefundText = "退款"
const jdComFinanceTransactionDataStatusSuccessName = "交易成功"
const jdComFinanceTransactionDataStatusRefundSuccessName = "退款成功"
var jdComFinanceTransactionSupportedColumns = 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 jdComFinanceTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
}
// jdComFinanceTransactionDataRowParser defines the structure of jd.com finance transaction data row parser
type jdComFinanceTransactionDataRowParser struct {
existedOriginalDataColumns map[string]bool
}
// Parse returns the converted transaction data row
func (p *jdComFinanceTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
if dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because type is \"%s\"", rowId, dataRow.GetData(jdComFinanceTransactionTypeColumnName))
return nil, false, nil
}
statusName := dataRow.GetData(jdComFinanceTransactionStatusColumnName)
if statusName != jdComFinanceTransactionDataStatusSuccessName &&
statusName != jdComFinanceTransactionDataStatusRefundSuccessName {
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because status is \"%s\"", rowId, statusName)
return nil, false, nil
}
data := make(map[datatable.TransactionDataTableColumn]string, len(jdComFinanceTransactionSupportedColumns))
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(jdComFinanceTransactionTimeColumnName)
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(jdComFinanceTransactionTypeColumnName)
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(jdComFinanceTransactionCategoryColumnName)
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionRelatedAccountColumnName)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
if strings.Index(dataRow.GetData(jdComFinanceTransactionAmountColumnName), "(") >= 0 {
// If a transaction includes a refund, the original transaction amount will like "-xx.xx(已全额退款)" or "-xx.xx(已退款yy.yy)", along with another refund transaction
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = strings.Split(dataRow.GetData(jdComFinanceTransactionAmountColumnName), "(")[0]
} else {
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(jdComFinanceTransactionAmountColumnName)
}
if p.hasOriginalColumn(jdComFinanceTransactionDescriptionColumnName) && dataRow.GetData(jdComFinanceTransactionDescriptionColumnName) != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(jdComFinanceTransactionDescriptionColumnName)
} else if p.hasOriginalColumn(jdComFinanceTransactionMemoColumnName) && dataRow.GetData(jdComFinanceTransactionMemoColumnName) != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(jdComFinanceTransactionMemoColumnName)
} else {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
}
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
memo := dataRow.GetData(jdComFinanceTransactionMemoColumnName)
if statusName == jdComFinanceTransactionDataStatusRefundSuccessName || strings.Index(memo, jdComFinanceTransactionMemoRefundText) >= 0 { // refund
amount, err := utils.ParseAmount(data[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err == nil {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
}
} else if strings.Index(dataRow.GetData(jdComFinanceTransactionAmountColumnName), jdComFinanceTransactionAmountRefundAll) > 0 { // expense transaction (but include a full refund)
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
} else { // transfer
if strings.Index(memo, jdComFinanceTransactionMemoTransferToWalletPrefix) >= 0 { // transfer to jd.com finance wallet
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferFromWalletPrefix) >= 0 { // transfer from jd.com finance wallet
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferInText) >= 0 { // transfer in
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferOutText) >= 0 { // transfer out
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
} else if strings.Index(memo, jdComFinanceTransactionMemoRepaymentText) >= 0 { // repayment
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
} else {
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because memo (\"%s\") of this transfer transaction is unknown", rowId, memo)
return nil, false, nil
}
}
}
return data, true, nil
}
func (p *jdComFinanceTransactionDataRowParser) hasOriginalColumn(columnName string) bool {
_, exists := p.existedOriginalDataColumns[columnName]
return exists
}
// createJDComFinanceTransactionDataRowParser returns jd.com finance transaction data row parser
func createJDComFinanceTransactionDataRowParser(headerColumnNames []string) datatable.CommonTransactionDataRowParser {
existedOriginalDataColumns := make(map[string]bool, len(headerColumnNames))
for i := 0; i < len(headerColumnNames); i++ {
existedOriginalDataColumns[headerColumnNames[i]] = true
}
return &jdComFinanceTransactionDataRowParser{
existedOriginalDataColumns: existedOriginalDataColumns,
}
}
+13 -4
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"io"
"regexp" "regexp"
"strings" "strings"
@@ -269,19 +270,27 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
func readOFX2FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, err error) { func readOFX2FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, err error) {
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
scanner := bufio.NewScanner(reader) bufReader := bufio.NewReader(reader)
fileHeader = &ofxFileHeader{} fileHeader = &ofxFileHeader{}
headerLine := "" headerLine := ""
for scanner.Scan() { for {
line := scanner.Text() line, err := bufReader.ReadString('\n')
ofxHeaderStartIndex := strings.Index(line, "<?OFX ") ofxHeaderStartIndex := strings.Index(line, "<?OFX ")
if ofxHeaderStartIndex >= 0 { if ofxHeaderStartIndex >= 0 {
headerLine = ofx2HeaderPattern.FindString(line) headerLine = ofx2HeaderPattern.FindString(line)
break break
} }
if err != nil {
if err == io.EOF {
break
} else {
log.Errorf(ctx, "[ofx_data_reader.readOFX2FileHeader] cannot read ofx 2.x file, because %s", err.Error())
return nil, errs.ErrInvalidOFXFile
}
}
} }
if headerLine == "" { if headerLine == "" {
@@ -12,6 +12,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII" "github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash" "github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
"github.com/mayswind/ezbookkeeping/pkg/converters/iif" "github.com/mayswind/ezbookkeeping/pkg/converters/iif"
"github.com/mayswind/ezbookkeeping/pkg/converters/jdcom"
"github.com/mayswind/ezbookkeeping/pkg/converters/mt" "github.com/mayswind/ezbookkeeping/pkg/converters/mt"
"github.com/mayswind/ezbookkeeping/pkg/converters/ofx" "github.com/mayswind/ezbookkeeping/pkg/converters/ofx"
"github.com/mayswind/ezbookkeeping/pkg/converters/qif" "github.com/mayswind/ezbookkeeping/pkg/converters/qif"
@@ -37,6 +38,8 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
return _default.DefaultTransactionDataCSVFileConverter, nil return _default.DefaultTransactionDataCSVFileConverter, nil
} else if fileType == "ezbookkeeping_tsv" { } else if fileType == "ezbookkeeping_tsv" {
return _default.DefaultTransactionDataTSVFileConverter, nil return _default.DefaultTransactionDataTSVFileConverter, nil
} else if fileType == "ezbookkeeping_json" {
return _default.DefaultTransactionDataJsonFileImporter, nil
} else if fileType == "ofx" { } else if fileType == "ofx" {
return ofx.OFXTransactionDataImporter, nil return ofx.OFXTransactionDataImporter, nil
} else if fileType == "qfx" { } else if fileType == "qfx" {
@@ -69,8 +72,12 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
return alipay.AlipayAppTransactionDataCsvFileImporter, nil return alipay.AlipayAppTransactionDataCsvFileImporter, nil
} else if fileType == "alipay_web_csv" { } else if fileType == "alipay_web_csv" {
return alipay.AlipayWebTransactionDataCsvFileImporter, nil return alipay.AlipayWebTransactionDataCsvFileImporter, nil
} else if fileType == "wechat_pay_app_xlsx" {
return wechat.WeChatPayTransactionDataXlsxFileImporter, nil
} else if fileType == "wechat_pay_app_csv" { } else if fileType == "wechat_pay_app_csv" {
return wechat.WeChatPayTransactionDataCsvFileImporter, nil return wechat.WeChatPayTransactionDataCsvFileImporter, nil
} else if fileType == "jdcom_finance_app_csv" {
return jdcom.JDComFinanceTransactionDataCsvFileImporter, nil
} else { } else {
return nil, errs.ErrImportFileTypeNotSupported return nil, errs.ErrImportFileTypeNotSupported
} }
@@ -2,14 +2,12 @@ package wechat
import ( import (
"bytes" "bytes"
"encoding/csv"
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform" "golang.org/x/text/transform"
"io"
"strings"
"github.com/mayswind/ezbookkeeping/pkg/converters/converter" "github.com/mayswind/ezbookkeeping/pkg/converters/converter"
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv" "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable" "github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -17,22 +15,6 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/models"
) )
var wechatPayTransactionSupportedColumns = 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 wechatPayTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
models.TRANSACTION_TYPE_TRANSFER: "/",
}
// wechatPayTransactionDataCsvFileImporter defines the structure of wechatPay csv importer for transaction data // wechatPayTransactionDataCsvFileImporter defines the structure of wechatPay csv importer for transaction data
type wechatPayTransactionDataCsvFileImporter struct { type wechatPayTransactionDataCsvFileImporter struct {
fileHeaderLineBeginning string fileHeaderLineBeginning string
@@ -49,7 +31,13 @@ func (c *wechatPayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Con
fallback := unicode.UTF8.NewDecoder() fallback := unicode.UTF8.NewDecoder()
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback)) reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
dataTable, err := c.createNewWeChatPayBasicDataTable(ctx, reader) csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTable, err := createNewWeChatPayTransactionBasicDataTable(ctx, csvDataTable)
if err != nil { if err != nil {
return nil, nil, nil, nil, nil, nil, err return nil, nil, nil, nil, nil, nil, err
@@ -72,78 +60,3 @@ func (c *wechatPayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Con
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap) return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
} }
func (c *wechatPayTransactionDataCsvFileImporter) createNewWeChatPayBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, 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, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse wechat pay csv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if !hasFileHeader {
if len(items) <= 0 {
continue
} else if strings.Index(items[0], wechatPayTransactionDataCsvFileHeader) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] 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], wechatPayTransactionDataHeaderStartContentBeginning) == 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if len(items) <= 0 {
continue
}
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, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] 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, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
return dataTable, nil
}
@@ -0,0 +1,75 @@
package wechat
import (
"strings"
"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"
)
func createNewWeChatPayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
iterator := originalDataTable.DataRowIterator()
allOriginalLines := make([][]string, 0)
hasFileHeader := false
foundContentBeforeDataHeaderLine := false
for iterator.HasNext() {
row := iterator.Next()
if !hasFileHeader {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), wechatPayTransactionDataCsvFileHeader) == 0 {
hasFileHeader = true
continue
} else {
log.Warnf(ctx, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
continue
}
}
if !foundContentBeforeDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
} else if strings.Index(row.GetData(0), wechatPayTransactionDataHeaderStartContentBeginning) == 0 {
foundContentBeforeDataHeaderLine = true
continue
} else {
continue
}
}
if foundContentBeforeDataHeaderLine {
if row.ColumnCount() <= 0 {
continue
}
items := make([]string, row.ColumnCount())
for i := 0; i < row.ColumnCount(); i++ {
items[i] = strings.Trim(row.GetData(i), " ")
}
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
log.Errorf(ctx, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), 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, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
return nil, errs.ErrNotFoundTransactionDataInFile
}
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
}
@@ -29,6 +29,22 @@ const wechatPayTransactionDataCategoryTransferFromWeChatWallet = "零钱提现"
const wechatPayTransactionDataStatusRefundName = "退款" const wechatPayTransactionDataStatusRefundName = "退款"
var wechatPayTransactionSupportedColumns = 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 wechatPayTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_INCOME: "收入",
models.TRANSACTION_TYPE_EXPENSE: "支出",
models.TRANSACTION_TYPE_TRANSFER: "/",
}
// weChatPayTransactionDataRowParser defines the structure of wechat pay transaction data row parser // weChatPayTransactionDataRowParser defines the structure of wechat pay transaction data row parser
type weChatPayTransactionDataRowParser struct { type weChatPayTransactionDataRowParser struct {
existedOriginalDataColumns map[string]bool existedOriginalDataColumns map[string]bool
@@ -0,0 +1,53 @@
package wechat
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// wechatPayTransactionDataXlsxFileImporter defines the structure of wechatPay xlsx importer for transaction data
type wechatPayTransactionDataXlsxFileImporter struct {
dataHeaderStartContentBeginning string
}
// Initialize a webchat pay transaction data xlsx file importer singleton instance
var (
WeChatPayTransactionDataXlsxFileImporter = &wechatPayTransactionDataXlsxFileImporter{}
)
// ParseImportedData returns the imported data by parsing the wechat pay transaction csv data
func (c *wechatPayTransactionDataXlsxFileImporter) 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) {
xlsxDataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data, false)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
dataTable, err := createNewWeChatPayTransactionBasicDataTable(ctx, xlsxDataTable)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
if !commonDataTable.HasColumn(wechatPayTransactionTimeColumnName) ||
!commonDataTable.HasColumn(wechatPayTransactionCategoryColumnName) ||
!commonDataTable.HasColumn(wechatPayTransactionTypeColumnName) ||
!commonDataTable.HasColumn(wechatPayTransactionAmountColumnName) ||
!commonDataTable.HasColumn(wechatPayTransactionStatusColumnName) {
log.Errorf(ctx, "[wechat_pay_transaction_data_xlsx_file_importer.ParseImportedData] cannot parse wechat pay xlsx data, because missing essential columns in header row")
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
}
transactionRowParser := createWeChatPayTransactionDataRowParser(dataTable.HeaderColumnNames())
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, wechatPayTransactionSupportedColumns, transactionRowParser)
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(wechatPayTransactionTypeNameMapping)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
+66
View File
@@ -0,0 +1,66 @@
package core
import "fmt"
// CalendarDisplayType represents calendar display type
type CalendarDisplayType byte
// Calendar Display Type
const (
CALENDAR_DISPLAY_TYPE_DEFAULT CalendarDisplayType = 0
CALENDAR_DISPLAY_TYPE_GREGORAIN CalendarDisplayType = 1
CALENDAR_DISPLAY_TYPE_BUDDHIST CalendarDisplayType = 2
CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_CHINESE CalendarDisplayType = 3
CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_PERSIAN CalendarDisplayType = 4
CALENDAR_DISPLAY_TYPE_INVALID CalendarDisplayType = 255
)
// String returns a textual representation of the calendar display type enum
func (f CalendarDisplayType) String() string {
switch f {
case CALENDAR_DISPLAY_TYPE_DEFAULT:
return "Default"
case CALENDAR_DISPLAY_TYPE_GREGORAIN:
return "Gregorian"
case CALENDAR_DISPLAY_TYPE_BUDDHIST:
return "Buddhist"
case CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_CHINESE:
return "Gregorian with Chinese Calendar"
case CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_PERSIAN:
return "Gregorian with Persian Calendar"
case CALENDAR_DISPLAY_TYPE_INVALID:
return "Invalid"
default:
return fmt.Sprintf("Invalid(%d)", int(f))
}
}
// DateDisplayType represents date display type
type DateDisplayType byte
// Date Display Type
const (
DATE_DISPLAY_TYPE_DEFAULT DateDisplayType = 0
DATE_DISPLAY_TYPE_GREGORAIN DateDisplayType = 1
DATE_DISPLAY_TYPE_BUDDHIST DateDisplayType = 2
DATE_DISPLAY_TYPE_PERSIAN DateDisplayType = 3
DATE_DISPLAY_TYPE_INVALID DateDisplayType = 255
)
// String returns a textual representation of the date display type enum
func (f DateDisplayType) String() string {
switch f {
case DATE_DISPLAY_TYPE_DEFAULT:
return "Default"
case DATE_DISPLAY_TYPE_GREGORAIN:
return "Gregorian"
case DATE_DISPLAY_TYPE_BUDDHIST:
return "Buddhist"
case DATE_DISPLAY_TYPE_PERSIAN:
return "Persian"
case DATE_DISPLAY_TYPE_INVALID:
return "Invalid"
default:
return fmt.Sprintf("Invalid(%d)", int(f))
}
}
+4
View File
@@ -0,0 +1,4 @@
package core
// O is a shortcut for map[string]any
type O map[string]any
+3
View File
@@ -1,7 +1,10 @@
package core package core
import "context"
// Context is the base context of ezBookkeeping // Context is the base context of ezBookkeeping
type Context interface { type Context interface {
context.Context
GetContextId() string GetContextId() string
GetClientLocale() string GetClientLocale() string
} }
+41
View File
@@ -3,6 +3,7 @@ package core
import ( import (
"net" "net"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -23,6 +24,11 @@ const RemoteClientPortHeader = "X-Real-Port"
// ClientTimezoneOffsetHeaderName represents the header name of client timezone offset // ClientTimezoneOffsetHeaderName represents the header name of client timezone offset
const ClientTimezoneOffsetHeaderName = "X-Timezone-Offset" const ClientTimezoneOffsetHeaderName = "X-Timezone-Offset"
const tokenHeaderName = "Authorization"
const tokenHeaderValuePrefix = "bearer "
const tokenQueryStringParam = "token"
const tokenCookieParam = "ebk_auth_token"
// WebContext represents the request and response context // WebContext represents the request and response context
type WebContext struct { type WebContext struct {
*gin.Context *gin.Context
@@ -118,6 +124,41 @@ func (c *WebContext) GetCurrentUid() int64 {
return claims.Uid return claims.Uid
} }
// GetTokenStringFromHeader returns the token string from the request header
func (c *WebContext) GetTokenStringFromHeader() string {
tokenHeader := c.GetHeader(tokenHeaderName)
if len(tokenHeader) < 7 || !strings.EqualFold(tokenHeader[:7], tokenHeaderValuePrefix) {
return ""
}
return tokenHeader[7:]
}
// GetTokenStringFromQueryString returns the token string from the request query string
func (c *WebContext) GetTokenStringFromQueryString() string {
return c.Query(tokenQueryStringParam)
}
// GetTokenStringFromCookie returns the token string from the request cookie
func (c *WebContext) GetTokenStringFromCookie() string {
tokenCookie, err := c.Cookie(tokenCookieParam)
if err != nil {
return ""
}
return tokenCookie
}
func (c *WebContext) SetTokenStringToCookie(token string, tokenExpiredTime int, path string) {
if token != "" {
c.SetCookie(tokenCookieParam, token, tokenExpiredTime, path, "", false, true)
} else {
c.SetCookie(tokenCookieParam, "", -1, path, "", false, true)
}
}
// GetClientLocale returns the client locale name // GetClientLocale returns the client locale name
func (c *WebContext) GetClientLocale() string { func (c *WebContext) GetClientLocale() string {
value := c.GetHeader(AcceptLanguageHeaderName) value := c.GetHeader(AcceptLanguageHeaderName)
+41 -4
View File
@@ -4,6 +4,40 @@ import (
"fmt" "fmt"
) )
// NumeralSystem represents the type of numeral system
type NumeralSystem byte
// Numeral System
const (
NUMERAL_SYSTEM_DEFAULT NumeralSystem = 0
NUMERAL_SYSTEM_WESTERN_ARABIC_NUMERALS NumeralSystem = 1
NUMERAL_SYSTEM_EASTERN_ARABIC_NUMERALS NumeralSystem = 2
NUMERAL_SYSTEM_PERSIAN_DIGITS NumeralSystem = 3
NUMERAL_SYSTEM_BURMESE_NUMERALS NumeralSystem = 4
NUMERAL_SYSTEM_DEVANAGARI_NUMERALS NumeralSystem = 5
NUMERAL_SYSTEM_INVALID NumeralSystem = 255
)
// String returns a textual representation of the decimal separator enum
func (f NumeralSystem) String() string {
switch f {
case NUMERAL_SYSTEM_DEFAULT:
return "Default"
case NUMERAL_SYSTEM_WESTERN_ARABIC_NUMERALS:
return "Western Arabic Numerals"
case NUMERAL_SYSTEM_EASTERN_ARABIC_NUMERALS:
return "Eastern Arabic Numerals"
case NUMERAL_SYSTEM_PERSIAN_DIGITS:
return "Persian Digits"
case NUMERAL_SYSTEM_BURMESE_NUMERALS:
return "Burmese Numerals"
case NUMERAL_SYSTEM_DEVANAGARI_NUMERALS:
return "Devanagari Numerals"
default:
return fmt.Sprintf("Invalid(%d)", int(f))
}
}
// DecimalSeparator represents the type of decimal separator // DecimalSeparator represents the type of decimal separator
type DecimalSeparator byte type DecimalSeparator byte
@@ -69,10 +103,11 @@ type DigitGroupingType byte
// Digit Grouping Type // Digit Grouping Type
const ( const (
DIGIT_GROUPING_TYPE_DEFAULT DigitGroupingType = 0 DIGIT_GROUPING_TYPE_DEFAULT DigitGroupingType = 0
DIGIT_GROUPING_TYPE_NONE DigitGroupingType = 1 DIGIT_GROUPING_TYPE_NONE DigitGroupingType = 1
DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR DigitGroupingType = 2 DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR DigitGroupingType = 2
DIGIT_GROUPING_TYPE_INVALID DigitGroupingType = 255 DIGIT_GROUPING_TYPE_INDIAN_NUMBER_GROUPING DigitGroupingType = 3
DIGIT_GROUPING_TYPE_INVALID DigitGroupingType = 255
) )
// String returns a textual representation of the digit grouping type enum // String returns a textual representation of the digit grouping type enum
@@ -84,6 +119,8 @@ func (d DigitGroupingType) String() string {
return "None" return "None"
case DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR: case DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR:
return "Thousands Separator" return "Thousands Separator"
case DIGIT_GROUPING_TYPE_INDIAN_NUMBER_GROUPING:
return "Indian Number Grouping"
case DIGIT_GROUPING_TYPE_INVALID: case DIGIT_GROUPING_TYPE_INVALID:
return "Invalid" return "Invalid"
default: default:
+15 -14
View File
@@ -76,23 +76,24 @@ type UserFeatureRestrictionType uint64
// User Feature Restriction Type // User Feature Restriction Type
const ( const (
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1 USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2 USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3 USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4 USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5 USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6 USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7 USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8 USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9 USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10 USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11 USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS UserFeatureRestrictionType = 12 USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS UserFeatureRestrictionType = 12
USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS UserFeatureRestrictionType = 13 USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS UserFeatureRestrictionType = 13
USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION UserFeatureRestrictionType = 14
) )
const userFeatureRestrictionTypeMinValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD const userFeatureRestrictionTypeMinValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD
const userFeatureRestrictionTypeMaxValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS const userFeatureRestrictionTypeMaxValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION
// String returns a textual representation of the restriction type of user features // String returns a textual representation of the restriction type of user features
func (t UserFeatureRestrictionType) String() string { func (t UserFeatureRestrictionType) String() string {
+1 -1
View File
@@ -95,7 +95,7 @@ func TestCronJobSchedulerContainerRepeatRun(t *testing.T) {
InMemoryDuplicateCheckerCleanupIntervalDuration: 60 * time.Second, InMemoryDuplicateCheckerCleanupIntervalDuration: 60 * time.Second,
}) })
duplicatechecker.Container.Current = checker duplicatechecker.SetDuplicateChecker(checker)
container := &CronJobSchedulerContainer{ container := &CronJobSchedulerContainer{
allJobsMap: make(map[string]*CronJob), allJobsMap: make(map[string]*CronJob),
+1 -1
View File
@@ -22,7 +22,7 @@ func (j *CronJob) doRun() {
start := time.Now() start := time.Now()
c := core.NewCronJobContext(j.Name, j.Period.GetInterval()) c := core.NewCronJobContext(j.Name, j.Period.GetInterval())
if duplicatechecker.Container.Current != nil { if duplicatechecker.Container.IsEnabled() {
localAddr, err := utils.GetLocalIPAddressesString() localAddr, err := utils.GetLocalIPAddressesString()
if err != nil { if err != nil {
+8 -8
View File
@@ -128,16 +128,16 @@ func getMysqlConnectionString(dbConfig *settings.DatabaseConfig) (string, error)
} }
func getPostgresConnectionString(dbConfig *settings.DatabaseConfig) (string, error) { func getPostgresConnectionString(dbConfig *settings.DatabaseConfig) (string, error) {
host, port, err := net.SplitHostPort(dbConfig.DatabaseHost)
if err != nil {
return "", errs.ErrDatabaseHostInvalid
}
if strings.HasPrefix(dbConfig.DatabaseHost, "/") { // unix socket path if strings.HasPrefix(dbConfig.DatabaseHost, "/") { // unix socket path
return fmt.Sprintf("postgres://%s:%s@:%s/%s?sslmode=%s&host=%s", return fmt.Sprintf("postgres:///%s?sslmode=%s&host=%s&user=%s&password=%s",
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode, host), nil dbConfig.DatabaseName, dbConfig.DatabaseSSLMode, dbConfig.DatabaseHost, url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword)), nil
} else { } else {
host, port, err := net.SplitHostPort(dbConfig.DatabaseHost)
if err != nil {
return "", errs.ErrDatabaseHostInvalid
}
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), host, port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode), nil url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), host, port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode), nil
} }
+67
View File
@@ -0,0 +1,67 @@
package datastore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
func TestGetMysqlConnectionString_TCP(t *testing.T) {
expectedValue := "username:password@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=true"
actualValue, err := getMysqlConnectionString(&settings.DatabaseConfig{
DatabaseType: "mysql",
DatabaseHost: "1.2.3.4:3306",
DatabaseName: "dbname",
DatabaseUser: "username",
DatabasePassword: "password",
})
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
}
func TestGetMysqlConnectionString_UnixSocket(t *testing.T) {
expectedValue := "username:password@unix(/path/to/mysql.sock)/dbname?charset=utf8mb4&parseTime=true"
actualValue, err := getMysqlConnectionString(&settings.DatabaseConfig{
DatabaseType: "mysql",
DatabaseHost: "/path/to/mysql.sock",
DatabaseName: "dbname",
DatabaseUser: "username",
DatabasePassword: "password",
})
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
}
func TestGetPostgreSQLConnectionString_TCP(t *testing.T) {
expectedValue := "postgres://username:password@1.2.3.4:5432/dbname?sslmode=disable"
actualValue, err := getPostgresConnectionString(&settings.DatabaseConfig{
DatabaseType: "postgres",
DatabaseHost: "1.2.3.4:5432",
DatabaseName: "dbname",
DatabaseUser: "username",
DatabasePassword: "password",
DatabaseSSLMode: "disable",
})
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
}
func TestGetPostgreSQLConnectionString_UnixSocket(t *testing.T) {
expectedValue := "postgres:///dbname?sslmode=disable&host=/path/to/postgres.sock&user=username&password=password"
actualValue, err := getPostgresConnectionString(&settings.DatabaseConfig{
DatabaseType: "postgres",
DatabaseHost: "/path/to/postgres.sock",
DatabaseName: "dbname",
DatabaseUser: "username",
DatabasePassword: "password",
DatabaseSSLMode: "disable",
})
assert.Nil(t, err)
assert.Equal(t, expectedValue, actualValue)
}
@@ -9,7 +9,7 @@ import (
// DuplicateCheckerContainer contains the current duplicate checker // DuplicateCheckerContainer contains the current duplicate checker
type DuplicateCheckerContainer struct { type DuplicateCheckerContainer struct {
Current DuplicateChecker current DuplicateChecker
} }
// Initialize a duplicate checker container singleton instance // Initialize a duplicate checker container singleton instance
@@ -21,7 +21,7 @@ var (
func InitializeDuplicateChecker(config *settings.Config) error { func InitializeDuplicateChecker(config *settings.Config) error {
if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType { if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType {
checker, err := NewInMemoryDuplicateChecker(config) checker, err := NewInMemoryDuplicateChecker(config)
Container.Current = checker Container.current = checker
return err return err
} }
@@ -29,37 +29,75 @@ func InitializeDuplicateChecker(config *settings.Config) error {
return errs.ErrInvalidDuplicateCheckerType return errs.ErrInvalidDuplicateCheckerType
} }
// SetDuplicateChecker sets the current duplicate checker
func SetDuplicateChecker(checker DuplicateChecker) {
Container.current = checker
}
// IsEnabled returns whether the duplicate checker is enabled
func (c *DuplicateCheckerContainer) IsEnabled() bool {
return c.current != nil
}
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker // GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
func (c *DuplicateCheckerContainer) GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) { func (c *DuplicateCheckerContainer) GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) {
return c.Current.GetSubmissionRemark(checkerType, uid, identification) if c.current == nil {
return false, ""
}
return c.current.GetSubmissionRemark(checkerType, uid, identification)
} }
// SetSubmissionRemark saves the identification and remark by the current duplicate checker // SetSubmissionRemark saves the identification and remark by the current duplicate checker
func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string) { func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string) {
c.Current.SetSubmissionRemark(checkerType, uid, identification, remark) if c.current == nil {
return
}
c.current.SetSubmissionRemark(checkerType, uid, identification, remark)
} }
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker // RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) { func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) {
c.Current.RemoveSubmissionRemark(checkerType, uid, identification) if c.current == nil {
return
}
c.current.RemoveSubmissionRemark(checkerType, uid, identification)
} }
// GetOrSetCronJobRunningInfo returns the running info when the cron job is running or saves the running info by the current duplicate checker // GetOrSetCronJobRunningInfo returns the running info when the cron job is running or saves the running info by the current duplicate checker
func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string) { func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string) {
return c.Current.GetOrSetCronJobRunningInfo(jobName, runningInfo, runningInterval) if c.current == nil {
return false, ""
}
return c.current.GetOrSetCronJobRunningInfo(jobName, runningInfo, runningInterval)
} }
// RemoveCronJobRunningInfo removes the running info of the cron job by the current duplicate checker // RemoveCronJobRunningInfo removes the running info of the cron job by the current duplicate checker
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) { func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
c.Current.RemoveCronJobRunningInfo(jobName) if c.current == nil {
return
}
c.current.RemoveCronJobRunningInfo(jobName)
} }
// GetFailureCount returns the failure count of the specified failure key // GetFailureCount returns the failure count of the specified failure key
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 { func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
return c.Current.GetFailureCount(failureKey) if c.current == nil {
return 0
}
return c.current.GetFailureCount(failureKey)
} }
// IncreaseFailureCount increases the failure count of the specified failure key // IncreaseFailureCount increases the failure count of the specified failure key
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 { func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
return c.Current.IncreaseFailureCount(failureKey) if c.current == nil {
return 0
}
return c.current.IncreaseFailureCount(failureKey)
} }
+1
View File
@@ -30,4 +30,5 @@ var (
ErrInvalidAmountExpression = NewNormalError(NormalSubcategoryConverter, 23, http.StatusBadRequest, "invalid amount expression") ErrInvalidAmountExpression = NewNormalError(NormalSubcategoryConverter, 23, http.StatusBadRequest, "invalid amount expression")
ErrInvalidXmlFile = NewNormalError(NormalSubcategoryConverter, 24, http.StatusBadRequest, "invalid xml file") ErrInvalidXmlFile = NewNormalError(NormalSubcategoryConverter, 24, http.StatusBadRequest, "invalid xml file")
ErrInvalidMT940File = NewNormalError(NormalSubcategoryConverter, 25, http.StatusBadRequest, "invalid mt940 file") ErrInvalidMT940File = NewNormalError(NormalSubcategoryConverter, 25, http.StatusBadRequest, "invalid mt940 file")
ErrInvalidJSONFile = NewNormalError(NormalSubcategoryConverter, 26, http.StatusBadRequest, "invalid json file")
) )
+1
View File
@@ -40,6 +40,7 @@ const (
NormalSubcategoryConverter = 12 NormalSubcategoryConverter = 12
NormalSubcategoryUserCustomExchangeRate = 13 NormalSubcategoryUserCustomExchangeRate = 13
NormalSubcategoryModelContextProtocol = 14 NormalSubcategoryModelContextProtocol = 14
NormalSubcategoryLargeLanguageModel = 15
) )
// Error represents the specific error returned to user // Error represents the specific error returned to user
+12
View File
@@ -0,0 +1,12 @@
package errs
import "net/http"
// Error codes related to large language model features
var (
ErrLargeLanguageModelProviderNotEnabled = NewNormalError(NormalSubcategoryLargeLanguageModel, 0, http.StatusBadRequest, "llm provider is not enabled")
ErrNoAIRecognitionImage = NewNormalError(NormalSubcategoryLargeLanguageModel, 1, http.StatusBadRequest, "no image for AI recognition")
ErrAIRecognitionImageIsEmpty = NewNormalError(NormalSubcategoryLargeLanguageModel, 2, http.StatusBadRequest, "image for AI recognition is empty")
ErrExceedMaxAIRecognitionImageFileSize = NewNormalError(NormalSubcategoryLargeLanguageModel, 3, http.StatusBadRequest, "exceed the maximum size of image file for AI recognition")
ErrNoTransactionInformationInImage = NewNormalError(NormalSubcategoryLargeLanguageModel, 4, http.StatusBadRequest, "no transaction information detected")
)
+2
View File
@@ -24,4 +24,6 @@ var (
ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 17, http.StatusInternalServerError, "invalid password reset token expired time") ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 17, http.StatusInternalServerError, "invalid password reset token expired time")
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 18, http.StatusInternalServerError, "invalid exchange rates data source") ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 18, http.StatusInternalServerError, "invalid exchange rates data source")
ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern") ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern")
ErrInvalidLLMProvider = NewSystemError(SystemSubcategorySetting, 20, http.StatusInternalServerError, "invalid llm provider")
ErrInvalidLLMModelId = NewSystemError(SystemSubcategorySetting, 21, http.StatusInternalServerError, "invalid llm model id")
) )
@@ -2,7 +2,6 @@ package exchangerates
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"io" "io"
"net/http" "net/http"
"sort" "sort"
@@ -25,13 +24,13 @@ type HttpExchangeRatesDataSource interface {
Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error)
} }
// CommonHttpExchangeRatesDataSource defines the structure of common http exchange rates data source // CommonHttpExchangeRatesDataProvider defines the structure of common http exchange rates data provider
type CommonHttpExchangeRatesDataSource struct { type CommonHttpExchangeRatesDataProvider struct {
ExchangeRatesDataSource ExchangeRatesDataProvider
dataSource HttpExchangeRatesDataSource dataSource HttpExchangeRatesDataSource
} }
func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) { func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy) utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy)
@@ -49,7 +48,7 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
requests, err := e.dataSource.BuildRequests() requests, err := e.dataSource.BuildRequests()
if err != nil { if err != nil {
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to build requests for user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi return nil, errs.ErrFailedToRequestRemoteApi
} }
@@ -59,7 +58,7 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
req := requests[i] req := requests[i]
if len(req.Header.Values("User-Agent")) < 1 { if len(req.Header.Values("User-Agent")) < 1 {
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version)) req.Header.Set("User-Agent", settings.GetUserAgent())
} else if req.Header.Get("User-Agent") == "" { } else if req.Header.Get("User-Agent") == "" {
req.Header.Del("User-Agent") req.Header.Del("User-Agent")
} }
@@ -67,24 +66,24 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] 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.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
return nil, errs.ErrFailedToRequestRemoteApi return nil, errs.ErrFailedToRequestRemoteApi
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
log.Debugf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] response#%d is %s", i, body) log.Debugf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] response#%d is %s", i, body)
if resp.StatusCode != 200 {
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not %d", uid, resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
exchangeRateResp, err := e.dataSource.Parse(c, body) exchangeRateResp, err := e.dataSource.Parse(c, body)
if err != nil { if err != nil {
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to parse response for user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi) return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
} }
@@ -126,8 +125,8 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
return finalExchangeRateResponse, nil return finalExchangeRateResponse, nil
} }
func newCommonHttpExchangeRatesDataSource(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataSource { func newCommonHttpExchangeRatesDataProvider(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
return &CommonHttpExchangeRatesDataSource{ return &CommonHttpExchangeRatesDataProvider{
dataSource: dataSource, dataSource: dataSource,
} }
} }
@@ -326,15 +326,12 @@ func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *mode
err := InitializeExchangeRatesDataSource(config) err := InitializeExchangeRatesDataSource(config)
assert.Nil(t, err) assert.Nil(t, err)
dataSource := Container.Current
assert.NotNil(t, dataSource)
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder()) ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
context := &core.WebContext{ context := &core.WebContext{
Context: ginContext, Context: ginContext,
} }
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(context, context.GetCurrentUid(), config) exchangeRateResponse, err := Container.GetLatestExchangeRates(context, context.GetCurrentUid(), config)
assert.Nil(t, err) assert.Nil(t, err)
assert.NotNil(t, exchangeRateResponse) assert.NotNil(t, exchangeRateResponse)
@@ -6,8 +6,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/settings"
) )
// ExchangeRatesDataSource defines the structure of exchange rates data source // ExchangeRatesDataProvider defines the structure of exchange rates data provider
type ExchangeRatesDataSource interface { type ExchangeRatesDataProvider interface {
// GetLatestExchangeRates returns the common response entities // GetLatestExchangeRates returns the common response entities
GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error)
} }
@@ -0,0 +1,88 @@
package exchangerates
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"
)
// ExchangeRatesDataProviderContainer contains the current exchange rates data provider
type ExchangeRatesDataProviderContainer struct {
current ExchangeRatesDataProvider
}
// Initialize a exchange rates data provider container singleton instance
var (
Container = &ExchangeRatesDataProviderContainer{}
)
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
func InitializeExchangeRatesDataSource(config *settings.Config) error {
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&ReserveBankOfAustraliaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfCanadaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&CzechNationalBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&DanmarksNationalbankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&EuroCentralBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfGeorgiaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfHungaryDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfIsraelDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfMyanmarDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&NorgesBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfPolandDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfRomaniaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfRussiaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&SwissNationalBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfUkraineDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfUzbekistanDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
Container.current = newCommonHttpExchangeRatesDataProvider(&InternationalMonetaryFundDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
Container.current = newUserCustomExchangeRatesDataProvider()
return nil
}
return errs.ErrInvalidExchangeRatesDataSource
}
// GetLatestExchangeRates returns the latest exchange rates data from the current exchange rates data source
func (e *ExchangeRatesDataProviderContainer) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
if Container.current == nil {
return nil, errs.ErrInvalidExchangeRatesDataSource
}
return e.current.GetLatestExchangeRates(c, uid, currentConfig)
}
@@ -1,77 +0,0 @@
package exchangerates
import (
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// ExchangeRatesDataSourceContainer contains the current exchange rates data source
type ExchangeRatesDataSourceContainer struct {
Current ExchangeRatesDataSource
}
// Initialize a exchange rates data source container singleton instance
var (
Container = &ExchangeRatesDataSourceContainer{}
)
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
func InitializeExchangeRatesDataSource(config *settings.Config) error {
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&ReserveBankOfAustraliaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfCanadaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&CzechNationalBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&DanmarksNationalbankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&EuroCentralBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfGeorgiaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfHungaryDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfIsraelDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfMyanmarDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&NorgesBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfPolandDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfRomaniaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfRussiaDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&SwissNationalBankDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfUkraineDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfUzbekistanDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
Container.Current = newCommonHttpExchangeRatesDataSource(&InternationalMonetaryFundDataSource{})
return nil
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
Container.Current = newUserCustomExchangeRatesDataSource()
return nil
}
return errs.ErrInvalidExchangeRatesDataSource
}
@@ -15,25 +15,25 @@ import (
const userDataSourceType = "user_custom" const userDataSourceType = "user_custom"
// UserCustomExchangeRatesDataSource defines the structure of user custom exchange rates data source // UserCustomExchangeRatesDataProvider defines the structure of user custom exchange rates data provider
type UserCustomExchangeRatesDataSource struct { type UserCustomExchangeRatesDataProvider struct {
ExchangeRatesDataSource ExchangeRatesDataProvider
users *services.UserService users *services.UserService
userCustomExchangeRates *services.UserCustomExchangeRatesService userCustomExchangeRates *services.UserCustomExchangeRatesService
} }
func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) { func (e *UserCustomExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
user, err := e.users.GetUserById(c, uid) user, err := e.users.GetUserById(c, uid)
if err != nil { if err != nil {
log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[user_custom_data_provider.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
customExchangeRates, err := e.userCustomExchangeRates.GetAllCustomExchangeRatesByUid(c, uid) customExchangeRates, err := e.userCustomExchangeRates.GetAllCustomExchangeRatesByUid(c, uid)
if err != nil { if err != nil {
log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error()) log.Errorf(c, "[user_custom_data_provider.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed) return nil, errs.Or(err, errs.ErrOperationFailed)
} }
@@ -93,8 +93,8 @@ func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
return finalExchangeRateResponse, nil return finalExchangeRateResponse, nil
} }
func newUserCustomExchangeRatesDataSource() *UserCustomExchangeRatesDataSource { func newUserCustomExchangeRatesDataProvider() *UserCustomExchangeRatesDataProvider {
return &UserCustomExchangeRatesDataSource{ return &UserCustomExchangeRatesDataProvider{
users: services.Users, users: services.Users,
userCustomExchangeRates: services.UserCustomExchangeRates, userCustomExchangeRates: services.UserCustomExchangeRates,
} }
+34
View File
@@ -0,0 +1,34 @@
package data
import "reflect"
type LargeLanguageModelRequestPromptType byte
// Large Language Model Request Prompt Type
const (
LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_TEXT LargeLanguageModelRequestPromptType = 0
LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL LargeLanguageModelRequestPromptType = 1
)
type LargeLanguageModelResponseFormat byte
// Large Language Model Response Format
const (
LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_TEXT LargeLanguageModelResponseFormat = 0
LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON LargeLanguageModelResponseFormat = 1
)
// LargeLanguageModelRequest represents a request to a large language model
type LargeLanguageModelRequest struct {
Stream bool
SystemPrompt string
UserPrompt []byte
UserPromptType LargeLanguageModelRequestPromptType
UserPromptContentType string
ResponseJsonObjectType reflect.Type
}
// LargeLanguageModelTextualResponse represents a textual response from a large language model
type LargeLanguageModelTextualResponse struct {
Content string
}
@@ -0,0 +1,64 @@
package llm
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// LargeLanguageModelProviderContainer contains the current large language model provider
type LargeLanguageModelProviderContainer struct {
receiptImageRecognitionCurrentProvider provider.LargeLanguageModelProvider
}
// Initialize a large language model provider container singleton instance
var (
Container = &LargeLanguageModelProviderContainer{}
)
// InitializeLargeLanguageModelProvider initializes the current large language model provider according to the config
func InitializeLargeLanguageModelProvider(config *settings.Config) error {
var err error = nil
if config.ReceiptImageRecognitionLLMConfig != nil {
Container.receiptImageRecognitionCurrentProvider, err = initializeLargeLanguageModelProvider(config.ReceiptImageRecognitionLLMConfig)
if err != nil {
return err
}
}
return nil
}
func initializeLargeLanguageModelProvider(llmConfig *settings.LLMConfig) (provider.LargeLanguageModelProvider, error) {
if llmConfig.LLMProvider == settings.OpenAILLMProvider {
return openai.NewOpenAILargeLanguageModelProvider(llmConfig), nil
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig), nil
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig), nil
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig), nil
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig), nil
} else if llmConfig.LLMProvider == "" {
return nil, nil
}
return nil, errs.ErrInvalidLLMProvider
}
// GetJsonResponseByReceiptImageRecognitionModel returns the json response from the current large language model provider by receipt image recognition model
func (l *LargeLanguageModelProviderContainer) GetJsonResponseByReceiptImageRecognitionModel(c core.Context, uid int64, currentConfig *settings.Config, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error) {
if currentConfig.ReceiptImageRecognitionLLMConfig == nil || Container.receiptImageRecognitionCurrentProvider == nil {
return nil, errs.ErrInvalidLLMProvider
}
return l.receiptImageRecognitionCurrentProvider.GetJsonResponse(c, uid, currentConfig.ReceiptImageRecognitionLLMConfig, request)
}
@@ -0,0 +1,102 @@
package common
import (
"crypto/tls"
"io"
"net/http"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// HttpLargeLanguageModelAdapter defines the structure of http large language model adapter
type HttpLargeLanguageModelAdapter interface {
// BuildTextualRequest returns the http request by the provider api definition
BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error)
// ParseTextualResponse returns the textual response entity by the provider api definition
ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error)
}
// CommonHttpLargeLanguageModelProvider defines the structure of common http large language model provider
type CommonHttpLargeLanguageModelProvider struct {
provider.LargeLanguageModelProvider
adapter HttpLargeLanguageModelAdapter
}
// GetJsonResponse returns the json response from common http large language model provider
func (p *CommonHttpLargeLanguageModelProvider) GetJsonResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error) {
response, err := p.getTextualResponse(c, uid, currentLLMConfig, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
if err != nil {
return nil, err
}
if strings.HasPrefix(response.Content, "```json") && strings.HasSuffix(response.Content, "```") {
response.Content = strings.TrimPrefix(response.Content, "```json")
response.Content = strings.TrimSuffix(response.Content, "```")
} else if strings.HasPrefix(response.Content, "```") && strings.HasSuffix(response.Content, "```") {
response.Content = strings.TrimPrefix(response.Content, "```")
response.Content = strings.TrimSuffix(response.Content, "```")
}
return response, nil
}
func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
utils.SetProxyUrl(transport, currentLLMConfig.LargeLanguageModelAPIProxy)
if currentLLMConfig.LargeLanguageModelAPISkipTLSVerify {
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
client := &http.Client{
Transport: transport,
Timeout: time.Duration(currentLLMConfig.LargeLanguageModelAPIRequestTimeout) * time.Millisecond,
}
httpRequest, err := p.adapter.BuildTextualRequest(c, uid, request, responseType)
if err != nil {
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
httpRequest.Header.Set("User-Agent", settings.GetUserAgent())
resp, err := client.Do(httpRequest)
if err != nil {
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to request large language model api for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
log.Debugf(c, "[common_http_large_language_model_provider.getTextualResponse] response is %s", body)
if resp.StatusCode != 200 {
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to get large language model api response for user \"uid:%d\", because response code is %d", uid, resp.StatusCode)
return nil, errs.ErrFailedToRequestRemoteApi
}
return p.adapter.ParseTextualResponse(c, uid, body, responseType)
}
// NewCommonHttpLargeLanguageModelProvider creates a http adapter based large language model provider instance
func NewCommonHttpLargeLanguageModelProvider(adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
return &CommonHttpLargeLanguageModelProvider{
adapter: adapter,
}
}
@@ -0,0 +1,167 @@
package googleai
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
const googleAIGenerateContentAPIFormat = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
// GoogleAILargeLanguageModelAdapter defines the structure of Google AI large language model adapter
type GoogleAILargeLanguageModelAdapter struct {
common.HttpLargeLanguageModelAdapter
GoogleAIAPIKey string
GoogleAIModelID string
}
// GoogleAIGenerateContentRequest defines the structure of Google AI generate content request
type GoogleAIGenerateContentRequest struct {
Contents []*GoogleAIGenerateContentRequestContent `json:"contents"`
}
// GoogleAIGenerateContentRequestContent defines the structure of Google AI generate content request content
type GoogleAIGenerateContentRequestContent struct {
Parts []*GoogleAIGenerateContentRequestContentPart `json:"parts"`
}
// GoogleAIGenerateContentRequestContentPart defines the structure of Google AI generate content request content part
type GoogleAIGenerateContentRequestContentPart struct {
Text string `json:"text,omitempty"`
InlineData *GoogleAIGenerateContentRequestInlineData `json:"inlineData,omitempty"`
}
// GoogleAIGenerateContentRequestInlineData defines the structure of Google AI generate content request inline data
type GoogleAIGenerateContentRequestInlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
}
// GoogleAIGenerateContentResponse defines the structure of Google AI generate content response
type GoogleAIGenerateContentResponse struct {
Candidates []*GoogleAIGenerateContentResponseCandidate `json:"candidates"`
}
// GoogleAIGenerateContentResponseCandidate defines the structure of Google AI generate content response candidate
type GoogleAIGenerateContentResponseCandidate struct {
Content *GoogleAIGenerateContentResponseContent `json:"content"`
}
// GoogleAIGenerateContentResponseContent defines the structure of Google AI generate content response content
type GoogleAIGenerateContentResponseContent struct {
Part []*GoogleAIGenerateContentResponseContentPart `json:"parts"`
}
// GoogleAIGenerateContentResponseContentPart defines the structure of Google AI generate content response content part
type GoogleAIGenerateContentResponseContentPart struct {
Text *string `json:"text"`
}
// BuildTextualRequest returns the http request by Google AI large language model adapter
func (p *GoogleAILargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
if err != nil {
return nil, err
}
requestUrl := fmt.Sprintf(googleAIGenerateContentAPIFormat, p.GoogleAIModelID)
httpRequest, err := http.NewRequest("POST", requestUrl, bytes.NewReader(requestBody))
if err != nil {
return nil, err
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("X-goog-api-key", p.GoogleAIAPIKey)
return httpRequest, nil
}
// ParseTextualResponse returns the textual response by Google AI large language model adapter
func (p *GoogleAILargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
generateContentResponse := &GoogleAIGenerateContentResponse{}
err := json.Unmarshal(body, &generateContentResponse)
if err != nil {
log.Errorf(c, "[google_ai_large_language_model_adapter.ParseTextualResponse] failed to parse generate content response for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrFailedToRequestRemoteApi
}
if generateContentResponse == nil || generateContentResponse.Candidates == nil || len(generateContentResponse.Candidates) < 1 ||
generateContentResponse.Candidates[0].Content == nil || len(generateContentResponse.Candidates[0].Content.Part) < 1 ||
generateContentResponse.Candidates[0].Content.Part[0].Text == nil {
log.Errorf(c, "[google_ai_large_language_model_adapter.ParseTextualResponse] generate content response is invalid for user \"uid:%d\"", uid)
return nil, errs.ErrFailedToRequestRemoteApi
}
textualResponse := &data.LargeLanguageModelTextualResponse{
Content: *generateContentResponse.Candidates[0].Content.Part[0].Text,
}
return textualResponse, nil
}
func (p *GoogleAILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
if p.GoogleAIModelID == "" {
return nil, errs.ErrInvalidLLMModelId
}
generateContentRequest := &GoogleAIGenerateContentRequest{
Contents: []*GoogleAIGenerateContentRequestContent{
{
Parts: make([]*GoogleAIGenerateContentRequestContentPart, 0, 2),
},
},
}
if request.SystemPrompt != "" {
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
Text: request.SystemPrompt,
})
}
if len(request.UserPrompt) > 0 {
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
imageBase64Data := base64.StdEncoding.EncodeToString(request.UserPrompt)
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
InlineData: &GoogleAIGenerateContentRequestInlineData{
MimeType: request.UserPromptContentType,
Data: imageBase64Data,
},
})
} else {
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
Text: string(request.UserPrompt),
})
}
}
requestBodyBytes, err := json.Marshal(generateContentRequest)
if err != nil {
log.Errorf(c, "[google_ai_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
log.Debugf(c, "[google_ai_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
return requestBodyBytes, nil
}
// NewGoogleAILargeLanguageModelProvider creates a new Google AI large language model provider instance
func NewGoogleAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
return common.NewCommonHttpLargeLanguageModelProvider(&GoogleAILargeLanguageModelAdapter{
GoogleAIAPIKey: llmConfig.GoogleAIAPIKey,
GoogleAIModelID: llmConfig.GoogleAIModelID,
})
}
@@ -0,0 +1,181 @@
package googleai
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
)
func TestGoogleAILargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
request := &data.LargeLanguageModelRequest{
SystemPrompt: "You are a helpful assistant.",
UserPrompt: []byte("Hello, how are you?"),
}
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.Nil(t, err)
var body map[string]interface{}
err = json.Unmarshal(bodyBytes, &body)
assert.Nil(t, err)
assert.Equal(t, "{\"contents\":[{\"parts\":[{\"text\":\"You are a helpful assistant.\"},{\"text\":\"Hello, how are you?\"}]}]}", string(bodyBytes))
}
func TestGoogleAILargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
request := &data.LargeLanguageModelRequest{
SystemPrompt: "What's in this image?",
UserPrompt: []byte("fakedata"),
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
UserPromptContentType: "image/png",
}
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.Nil(t, err)
var body map[string]interface{}
err = json.Unmarshal(bodyBytes, &body)
assert.Nil(t, err)
assert.Equal(t, "{\"contents\":[{\"parts\":[{\"text\":\"What's in this image?\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"ZmFrZWRhdGE=\"}}]}]}", string(bodyBytes))
}
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
response := `{
"responseId": "test-123",
"modelVersion": "test",
"usageMetadata": {
"promptTokenCount": 13,
"candidatesTokenCount": 7,
"totalTokenCount": 20
},
"candidates": [
{
"content": {
"parts": [
{
"text": "This is a test response"
}
]
},
"finish_reason": "stop",
"index": 0
}
]
}`
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.Nil(t, err)
assert.Equal(t, "This is a test response", result.Content)
}
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_EmptyResponse(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
response := `{
"responseId": "test-123",
"modelVersion": "test",
"usageMetadata": {
"promptTokenCount": 13,
"candidatesTokenCount": 7,
"totalTokenCount": 20
},
"candidates": [
{
"content": {
"parts": [
{
"text": ""
}
]
},
"finish_reason": "stop",
"index": 0
}
]
}`
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.Nil(t, err)
assert.Equal(t, "", result.Content)
}
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_EmptyCandidates(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
response := `{
"responseId": "test-123",
"modelVersion": "test",
"usageMetadata": {
"promptTokenCount": 13,
"candidatesTokenCount": 7,
"totalTokenCount": 20
},
"candidates": []
}`
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.EqualError(t, err, "failed to request third party api")
}
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_NoPartText(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
response := `{
"responseId": "test-123",
"modelVersion": "test",
"usageMetadata": {
"promptTokenCount": 13,
"candidatesTokenCount": 7,
"totalTokenCount": 20
},
"candidates": [
{
"content": {
"parts": [
{
}
]
},
"finish_reason": "stop",
"index": 0
}
]
}`
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.EqualError(t, err, "failed to request third party api")
}
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
adapter := &GoogleAILargeLanguageModelAdapter{
GoogleAIModelID: "test",
}
response := "error"
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
assert.EqualError(t, err, "failed to request third party api")
}
@@ -0,0 +1,13 @@
package provider
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// LargeLanguageModelProvider defines the structure of large language model provider
type LargeLanguageModelProvider interface {
// GetJsonResponse returns the json response from the large language model provider
GetJsonResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error)
}

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