Compare commits

...

340 Commits

Author SHA1 Message Date
MaysWind 6787d0591e support skip specified tests when build release 2025-03-10 22:02:08 +08:00
MaysWind d78dada5ec fix cannot filter transfer in transaction with transaction tags (#82) 2025-03-09 23:40:27 +08:00
MaysWind 74844b9a99 limit the maximum count of password / token check failures per IP/user per minute (#33) 2025-03-09 23:38:54 +08:00
MaysWind a29ff0d553 show add transaction button in desktop navigation bar (#59) 2025-03-09 20:15:04 +08:00
MaysWind 6632dd64b3 fix time zone name not display when creating a new scheduled transaction 2025-03-09 16:57:11 +08:00
MaysWind a8c912c4c2 modify text 2025-03-09 16:50:27 +08:00
MaysWind 2a02816127 fix the selected item not in the center sometimes 2025-03-09 16:42:40 +08:00
MaysWind 66b950b4aa modify text 2025-03-09 16:29:49 +08:00
MaysWind 639bd9c5cd use the new popup select dialog 2025-03-09 16:23:23 +08:00
MaysWind 2bf8c0b501 update README.md 2025-03-09 02:13:07 +08:00
MaysWind 847d5121aa show localized language name in language selection popup 2025-03-09 02:02:29 +08:00
MaysWind ab6e89594e code refactor 2025-03-09 01:30:57 +08:00
MaysWind 0662427cec show currency code in currency select 2025-03-09 01:19:14 +08:00
MaysWind d3b283f623 code refactor 2025-03-09 00:45:58 +08:00
MaysWind 385d97ba15 code refactor 2025-03-09 00:45:48 +08:00
MaysWind 09574f1c75 code refactor 2025-03-09 00:39:54 +08:00
MaysWind 34247be52c show currency code in currency selection popup 2025-03-09 00:00:36 +08:00
MaysWind 56b1e1f565 not display text after title when afterField not set 2025-03-08 22:43:18 +08:00
MaysWind 7c6a3081ee update README.md 2025-03-07 01:51:30 +08:00
MaysWind beeeb1c059 modify language select style 2025-03-07 01:47:27 +08:00
MaysWind 47f70098df update translation 2025-03-07 00:10:40 +08:00
MaysWind 4e6b708834 update translation 2025-03-06 22:40:08 +08:00
MaysWind a359b07ef3 update README.md 2025-03-06 22:39:43 +08:00
tkymmm bb0524c559 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm d70ea1987a Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 001de8a1e0 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm f045e8ffcd Add japanese 2025-03-06 13:31:47 +08:00
tkymmm f8be1222d6 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 8848fe8b33 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm 32524dac56 Add japanese 2025-03-06 13:31:47 +08:00
tkymmm a50ecf4d9c Add Japanese translation 2025-03-06 13:31:47 +08:00
tkymmm 98fda4e5d5 Add japanese .go 2025-03-06 13:31:47 +08:00
tkymmm f0c111f02a Update all_locales.go 2025-03-06 13:31:47 +08:00
MaysWind bcc36e1533 modify option default value 2025-03-05 00:35:01 +08:00
MaysWind 872639fefa support sub path 2025-03-04 23:39:16 +08:00
MaysWind e83b959930 convert transaction type in import transaction dialog 2025-03-04 01:27:51 +08:00
MaysWind 3f8de39683 update README.md 2025-03-04 01:27:39 +08:00
MaysWind 9430f57a0b import transaction from custom delimiter-separated values file 2025-03-04 01:27:08 +08:00
MaysWind 703ceb44e4 modify file name 2025-03-02 15:51:45 +08:00
MaysWind 3954db9b99 fix default value not set (#80) 2025-03-01 21:48:34 +08:00
MaysWind 7abb5972eb modify style 2025-03-01 20:38:13 +08:00
MaysWind 377a4899b7 scheduled transaction supports start time and end time (#36) 2025-02-28 00:14:52 +08:00
MaysWind d769e833e7 increase export timeout 2025-02-27 23:04:19 +08:00
MaysWind 9786f96fe5 trim trailing zero in transaction amount when importing OFX file (#73) 2025-02-27 22:11:19 +08:00
MaysWind bd2a672c12 do not set custom user agent for IMF exchange rate data source 2025-02-21 00:17:15 +08:00
MaysWind 96cb45dd45 trim redundant space 2025-02-21 00:11:29 +08:00
MaysWind cf370c083b add format string for log content 2025-02-20 00:09:12 +08:00
MaysWind 43e1780dc8 update comment 2025-02-19 23:55:25 +08:00
MaysWind 6aac810450 not select all text when user actively selects text 2025-02-19 23:34:38 +08:00
MaysWind f14c283a83 upgrade golang to 1.24.0 2025-02-19 23:19:36 +08:00
MaysWind e78b2cafb1 upgrade node.js to 22.14.0, alpine base image to 3.21.3 2025-02-19 23:19:17 +08:00
MaysWind a9eaf011cd fix the parent account not displayed in the account list in the transaction list page 2025-02-19 23:04:27 +08:00
MaysWind 7fca519fd9 fix the incorrect order in account list when there are more than one accounts with multiple sub-accounts in one category 2025-02-19 22:59:30 +08:00
MaysWind a9a37b0c97 fix the Postgres database transaction cannot continue to execute after failure (#50) 2025-02-17 00:32:53 +08:00
MaysWind 8f55bd0df1 add log 2025-02-17 00:01:24 +08:00
MaysWind 85e88949c4 fix clientSessionId not regenerated when use duplicate in desktop version 2025-02-16 23:16:59 +08:00
MaysWind 80bcebbf66 add log 2025-02-16 01:04:46 +08:00
MaysWind 7cae873830 parse split transactions in IIF file into separate transactions 2025-02-16 01:04:24 +08:00
MaysWind 7a1b27927f support two digits year 2025-02-13 23:30:43 +08:00
MaysWind 306da60752 fix wrong log content 2025-02-13 23:19:52 +08:00
MaysWind 4274b90b1e remove unused code 2025-02-12 23:26:31 +08:00
MaysWind 0b5721671d code refactor 2025-02-12 23:26:03 +08:00
MaysWind 30575d15d0 modify parameter name 2025-02-12 23:21:54 +08:00
MaysWind 0ca2f8b4a7 fix the wrong account balance in transaction edit page due to #a0e3a269a0098d05fa1a17eee4cce393869fc5cc 2025-02-12 01:25:46 +08:00
MaysWind 2e01e5530c after the search bar is focused and the screen height is reduced, let the sheet scroll to top 2025-02-12 00:56:08 +08:00
MaysWind 13cc6a2cf0 fix some content exceeds the screen in landscape mode 2025-02-11 23:35:35 +08:00
MaysWind 35ba5dcc9f disable vue-i18n legacy mode 2025-02-11 22:40:19 +08:00
MaysWind ab58109e5e account edit page displays the debt amount instead of the balance for credit card and debt accounts 2025-02-11 00:45:23 +08:00
MaysWind 18a6d25ed6 code refactor 2025-02-10 23:12:02 +08:00
MaysWind a0e3a269a0 fix the wrong display order of savings accounts and certificate of deposit accounts 2025-02-09 23:41:44 +08:00
MaysWind 1658d0758c support input page number in transaction list for desktop 2025-02-09 22:17:17 +08:00
MaysWind 6f0c59bba4 fix the menu not disappear after clicking the page number 2025-02-09 22:04:44 +08:00
MaysWind 1e98a0df55 press enter / tab to use the current input page number 2025-02-09 21:54:38 +08:00
MaysWind fa77d3e837 code refactor 2025-02-09 21:52:48 +08:00
MaysWind a21bc7aad7 code refactor 2025-02-09 21:27:19 +08:00
MaysWind e665fac956 modify style 2025-02-09 21:13:22 +08:00
MaysWind e60c633e56 duplicate transaction with time / geographic location (#36) 2025-02-09 21:11:55 +08:00
MaysWind a444526743 modify style 2025-02-09 20:14:02 +08:00
MaysWind b65a246fcb upgrade third party dependencies 2025-02-09 19:59:57 +08:00
MaysWind c5e8a50033 fix text item key 2025-02-09 19:59:24 +08:00
MaysWind 6021c24da9 format code 2025-02-09 17:50:09 +08:00
MaysWind 0e4cd10376 remove compatibility code for migration 2025-02-09 17:28:20 +08:00
MaysWind f2c043a299 code refactor 2025-02-09 17:28:20 +08:00
MaysWind 596787b998 migrate transaction list page to composition API and typescript 2025-02-09 17:28:20 +08:00
MaysWind bb3a0c4444 update README.md 2025-02-09 17:28:19 +08:00
chrgm 624b9cb20b Add German translation 2025-02-09 17:27:15 +08:00
MaysWind 5a6c25d616 tree view selection sheet supports filtering content (#38) 2025-02-07 23:25:14 +08:00
MaysWind eb178e7bed set save button disabled when tag name is empty 2025-02-07 23:24:58 +08:00
MaysWind 373d71c124 transaction tag selection sheet supports filtering content (#38) 2025-02-07 23:24:41 +08:00
MaysWind 6618cfeceb modify style 2025-02-07 21:36:56 +08:00
MaysWind 58c1382570 show hidden tag when there are no available tags 2025-02-07 00:08:42 +08:00
MaysWind 721eb122bd code refactor 2025-02-06 23:36:03 +08:00
MaysWind 95205d2f1d code refactor 2025-02-06 23:29:08 +08:00
MaysWind b6efa91879 two column list item selection supports filtering content (#38) 2025-02-06 22:37:02 +08:00
MaysWind 29d7ee09c8 code refactor 2025-02-06 21:07:26 +08:00
MaysWind 68cb5bc523 remove unused code 2025-02-06 20:44:18 +08:00
MaysWind bd96b2398a add readonly modifier 2025-02-06 20:32:52 +08:00
MaysWind 1579882475 code refactor 2025-02-06 00:15:04 +08:00
MaysWind b077b99806 do not save transaction draft when category / account / tag is same as the initial value (#37) 2025-02-05 22:38:41 +08:00
MaysWind 9797e7e58f fix the account list page shows skeleton and account info at the same time when reloading the account list for mobile version 2025-02-05 21:46:08 +08:00
MaysWind 00f62fd608 code refactor 2025-02-05 00:19:01 +08:00
MaysWind 833e767e6c migrate transaction edit page to composition API and typescript 2025-02-05 00:02:40 +08:00
MaysWind 3e7b3297aa fix the date ranges of income and expense trends in home page is wrong (#49) 2025-02-04 21:49:43 +08:00
MaysWind 8e170a69e8 migrate template list page to composition API and typescript 2025-02-04 14:38:09 +08:00
MaysWind 00d6f5d473 code refactor 2025-02-04 14:27:22 +08:00
MaysWind 3c363788d8 migrate mobile home page to composition API and typescript 2025-02-04 13:05:32 +08:00
MaysWind cc920cff9a migrate transaction template store to composition API and typescript 2025-02-04 12:45:25 +08:00
MaysWind 5d78d56f0c not allow to sort data in table when importing transactions 2025-02-04 01:36:08 +08:00
MaysWind b9b47c4428 show red checkbox when data is invalid 2025-02-04 01:32:06 +08:00
MaysWind a5382e9fdd show error when no selected file to import 2025-02-04 01:30:54 +08:00
MaysWind 376f5b2650 migrate importing transaction dialog to composition API and typescript 2025-02-04 01:29:25 +08:00
MaysWind 61c5f75006 code refactor 2025-02-03 23:10:10 +08:00
MaysWind 9a6148fe6e code refactor 2025-02-03 21:27:09 +08:00
MaysWind ff6a558e96 code refactor 2025-02-03 20:38:37 +08:00
MaysWind 3ad45bebb7 migrate transaction tag filter page to composition API and typescript 2025-02-03 20:38:30 +08:00
MaysWind 6b152bd778 code refactor 2025-02-03 16:37:11 +08:00
MaysWind aacde2dfde migrate category filter page to composition API and typescript 2025-02-03 15:21:40 +08:00
MaysWind 6971eccb22 code refactor 2025-02-03 14:48:55 +08:00
MaysWind a5e7c483ef ofx 1.x supports utf-8 encoding (#48) 2025-02-02 17:52:17 +08:00
MaysWind 319f97bf9e use QEMU 8.1.5 2025-01-31 23:00:08 +08:00
MaysWind da31a67c52 upgrade actions 2025-01-31 22:33:50 +08:00
MaysWind af355e5b85 migrate account filter page to composition API and typescript 2025-01-29 22:49:14 +08:00
MaysWind ca9fe264b4 code refactor 2025-01-29 22:31:59 +08:00
MaysWind c07e937702 disable controls when importing transactions 2025-01-28 12:22:28 +08:00
MaysWind 371b88c6cd switch to basic tab after clicking duplicate button 2025-01-28 12:05:00 +08:00
MaysWind 782bc11950 migrate transaction store to composition API and typescript 2025-01-28 11:58:43 +08:00
MaysWind 50c3fee7dc fix cannot create transaction tag in transaction edit dialog 2025-01-28 00:40:42 +08:00
MaysWind 51c4e06e59 code refactor 2025-01-28 00:17:42 +08:00
MaysWind 2a84f44f2c code refactor 2025-01-28 00:17:34 +08:00
MaysWind 4878c7258d check whether event target is input element 2025-01-27 20:36:55 +08:00
MaysWind e2a4e0cb3f migrate account list page to composition API and typescript 2025-01-27 20:36:47 +08:00
MaysWind fd3457af84 code refactor 2025-01-27 15:37:55 +08:00
MaysWind 8c0a9062a2 code refactor 2025-01-27 01:22:49 +08:00
MaysWind 10d301aa3c code refactor 2025-01-27 00:45:53 +08:00
MaysWind d7193847c5 code refactor 2025-01-27 00:45:48 +08:00
MaysWind 8ea079679c code refactor 2025-01-27 00:42:23 +08:00
MaysWind 95c7b498ff code refactor 2025-01-27 00:38:43 +08:00
MaysWind 1156499b05 code refactor 2025-01-27 00:36:34 +08:00
MaysWind e92aaffc94 make currency property required 2025-01-27 00:16:42 +08:00
MaysWind 8dc38912c9 select amount value when click the amount text box and the amount value is zero (#36, #45) 2025-01-27 00:05:52 +08:00
MaysWind d228bf12bb update code order and add missing text item 2025-01-26 23:46:53 +08:00
Artemy Egorov 7dff8a2ed5 fix: a couple of entries 2025-01-26 23:40:35 +08:00
Artemy Egorov 814fe02949 feat: full ru frontend locale 2025-01-26 23:40:35 +08:00
Artemy Egorov da702d6316 feat: update other locales 2025-01-26 23:40:35 +08:00
Artemy Egorov fd909023f9 feat: backend russian locale 2025-01-26 23:40:35 +08:00
MaysWind c94c455b8b fix wrong size of button icon in safari 2025-01-26 00:49:15 +08:00
MaysWind d39b0ee077 upgrade third party dependencies 2025-01-25 23:45:20 +08:00
MaysWind 99a2f40a4e add English hint for language option when current language is not English 2025-01-25 23:22:45 +08:00
MaysWind acaad355ed migrate trends bar chart to composition API and typescript 2025-01-25 23:22:38 +08:00
MaysWind ad4b351a32 migrate batch replace dialog to composition API and typescript 2025-01-25 20:07:19 +08:00
MaysWind 2902fae1df code refactor 2025-01-25 20:02:14 +08:00
MaysWind a0b9ca7fae migrate entry and router file to typescript 2025-01-25 16:13:55 +08:00
MaysWind 05d8f8b9ab code refactor 2025-01-25 15:05:47 +08:00
MaysWind d074a9d54a format code 2025-01-25 15:05:37 +08:00
MaysWind d0274013cf fix typo 2025-01-25 15:03:53 +08:00
MaysWind 1e0169a9b7 remove unused code 2025-01-25 15:02:59 +08:00
MaysWind c619d2ecad code refactor 2025-01-25 14:55:40 +08:00
MaysWind a27a2556aa migrate transaction statistics page to composition API and typescript 2025-01-25 14:52:57 +08:00
MaysWind 8207373a05 migrate statistics store to composition API and typescript 2025-01-24 23:16:57 +08:00
MaysWind 986fab9cbf modify struct name 2025-01-24 22:28:31 +08:00
MaysWind 2025551f3c remove unused code 2025-01-24 22:25:34 +08:00
MaysWind 3d934ab018 remove unused code 2025-01-24 22:25:04 +08:00
MaysWind 6a59ed0984 code refactor 2025-01-24 00:42:43 +08:00
MaysWind 8fce3f2bcc migrate account edit page to composition API and typescript 2025-01-24 00:42:37 +08:00
MaysWind eca0574e41 code refactor 2025-01-23 23:40:34 +08:00
MaysWind fa044f5972 code refactor 2025-01-23 23:01:16 +08:00
MaysWind 6d758f338b code refactor 2025-01-23 22:33:14 +08:00
MaysWind 28322bad5e code refactor 2025-01-23 21:57:53 +08:00
MaysWind a9805b8fff migrate mobile pie chart to composition API and typescript 2025-01-23 21:29:41 +08:00
MaysWind eb16b7fbb8 code refactor 2025-01-23 21:10:00 +08:00
MaysWind 70428b6c96 migrate desktop pie chart to composition API and typescript 2025-01-23 00:06:05 +08:00
MaysWind 85557c2879 fix "Save Display Order" still displays bug when adjusting the order and then restoring the original order in the desktop version 2025-01-22 23:12:58 +08:00
MaysWind 5bf7f77520 migrate transaction category list page to composition API and typescript 2025-01-22 23:08:18 +08:00
MaysWind 3cdc7c947f remove unused code 2025-01-22 22:09:56 +08:00
MaysWind 84fc6b2ffb migrate user 2fa setting page to composition API and typescript 2025-01-22 21:47:00 +08:00
MaysWind e7612f6f0c code refactor 2025-01-21 23:47:28 +08:00
MaysWind 58097331da code refactor 2025-01-21 23:47:22 +08:00
MaysWind e053528abf migrate session list page /user security page to composition API and typescript 2025-01-21 23:38:06 +08:00
MaysWind 568abb6e03 update transaction tag icon style 2025-01-21 00:27:45 +08:00
MaysWind 852c7899b5 modify style 2025-01-20 23:59:47 +08:00
MaysWind f4998da4cd migrate user profile page to composition API and typescript 2025-01-20 23:56:09 +08:00
MaysWind 9d9e6ef9bd migrate amount filter page to composition API and typescript 2025-01-20 22:07:47 +08:00
MaysWind fb367174b6 not show session updates toast after password modified 2025-01-20 00:41:23 +08:00
MaysWind 87566010be code refactor 2025-01-20 00:40:46 +08:00
MaysWind 2f7cdfd786 code refactor 2025-01-20 00:40:25 +08:00
MaysWind 559e8259be migrate root store to composition API and typescript 2025-01-20 00:40:02 +08:00
MaysWind 929d3febb0 remove unused code 2025-01-19 22:20:19 +08:00
MaysWind 92df98c3fb migrate signup page to composition API and typescript 2025-01-19 22:13:06 +08:00
MaysWind 019c993313 code refactor 2025-01-19 21:06:26 +08:00
MaysWind 3c370b7ac7 migrate forget password / reset password / verify email page to composition API and typescript 2025-01-19 20:57:55 +08:00
MaysWind 1afd811aa8 migrate login page to composition API and typescript 2025-01-19 20:54:10 +08:00
MaysWind 0a999d56c7 code refactor 2025-01-19 19:14:23 +08:00
MaysWind 4f6988d775 code refactor 2025-01-19 18:26:00 +08:00
MaysWind 9f2bbe527e migrate desktop home page to composition API and typescript 2025-01-19 00:48:27 +08:00
MaysWind 965be837a3 migrate transaction category edit page to composition API and typescript 2025-01-18 23:47:49 +08:00
MaysWind f5f8b9a145 code refactor 2025-01-18 23:36:02 +08:00
MaysWind f3b6c1266d code refactor 2025-01-18 23:35:43 +08:00
MaysWind c675057ab1 code refactor 2025-01-18 22:50:13 +08:00
MaysWind 20e95e35aa migrate preset dialog to composition API and typescript 2025-01-18 22:33:58 +08:00
MaysWind f22666e756 migrate account store to composition API and typescript 2025-01-18 00:37:44 +08:00
MaysWind 3567ac170a set name column length to 64 2025-01-17 22:02:33 +08:00
MiGueL0n 260bd952d4 Fix error when adding transactions 2025-01-17 20:31:05 +08:00
MiGueL0n e294f04b04 Update es.go
Change variable name from "en" to "es" to fix errors.
2025-01-17 13:04:25 +08:00
MiGueL0n 27fa5625be Backend spanish language
Sorry is my first pull 🤦‍♂️ i forgot to add Spanish in backend structure.
2025-01-17 12:09:22 +08:00
MiGueL0n 62623e6a23 Update es.json
Fix the error of the app name translated into Spanish with its original name.
2025-01-17 12:09:22 +08:00
MiGueL0n 9df436d31e Changes to do the things right 🙃 2025-01-17 12:09:22 +08:00
MiGueL0n c03f74154b Added Spanish locale options and translations 2025-01-17 12:09:22 +08:00
MaysWind 749bdfd164 migrate lib/account.js to typescript 2025-01-17 00:37:41 +08:00
MaysWind 6878d5260d code refactor 2025-01-16 23:16:20 +08:00
MaysWind 4f21762533 migrate app lock settings page to composition API and typescript 2025-01-16 22:37:35 +08:00
MaysWind adebc96637 code refactor 2025-01-16 21:35:56 +08:00
MaysWind 3a50e6d2de fix error not display in desktop 2025-01-16 21:32:20 +08:00
MaysWind b09b66adc3 migrate transaction category preset page to composition API and typescript 2025-01-16 00:43:50 +08:00
MaysWind 6ef42a9303 code refactor 2025-01-16 00:25:50 +08:00
MaysWind 922c338387 code refactor 2025-01-15 23:15:42 +08:00
MaysWind dc4310c301 code refactor 2025-01-15 22:36:38 +08:00
MaysWind 0b7fd647e6 add noImplicitOverride and noPropertyAccessFromIndexSignature compiler options 2025-01-15 22:19:28 +08:00
MaysWind 081b270f04 migrate transaction categories page to composition API and typescript 2025-01-14 23:48:06 +08:00
MaysWind 29c09cb10a migrate unlock page to composition API and typescript 2025-01-14 23:36:31 +08:00
MaysWind cd2e6c1aae migrate two column select to composition API and typescript 2025-01-14 22:23:24 +08:00
MaysWind d3e6756c22 code refactor 2025-01-14 22:20:19 +08:00
MaysWind 7d31812055 code refactor 2025-01-14 21:20:40 +08:00
MaysWind 8ce871e9bb migrate two column list item selection sheet to composition API and typescript 2025-01-14 00:38:15 +08:00
MaysWind 30125f0faa modify style 2025-01-14 00:37:46 +08:00
MaysWind f4ea9a85f0 code refactor 2025-01-14 00:14:07 +08:00
MaysWind 593e123610 remove unused code 2025-01-13 23:55:19 +08:00
MaysWind 4e8eddd868 migrate user data management page to composition API and typescript 2025-01-13 23:19:06 +08:00
MaysWind 041dbcb5c7 code refactor 2025-01-13 23:05:21 +08:00
MaysWind 215a0163b6 fix the incorrect language default time format 2025-01-13 22:30:05 +08:00
MaysWind a38867ce01 not allow to input or paste when amount input is set to readonly or disabled (#36) 2025-01-13 22:03:55 +08:00
MaysWind 9026c526f8 code refactor 2025-01-13 21:59:09 +08:00
MaysWind c1f94a4499 code refactor 2025-01-13 21:40:17 +08:00
MaysWind 2be329974e migrate transaction category store to composition API and typescript 2025-01-12 23:47:56 +08:00
MaysWind abb26ac410 migrate app settings page to composition API and typescript 2025-01-12 20:39:15 +08:00
MaysWind c5f03165bc migrate exchange rates page to composition API and typescript 2025-01-12 19:23:20 +08:00
MaysWind 41452ac20b add exception log 2025-01-12 18:16:20 +08:00
MaysWind a285707b53 migrate tree view selection sheet to composition API and typescript 2025-01-12 17:42:01 +08:00
MaysWind cc9a5eea36 display prefix in one line 2025-01-12 17:20:08 +08:00
MaysWind 89d5cc31af migrate amount input to composition API and typescript 2025-01-12 16:53:20 +08:00
MaysWind f0e11e952c remove unused code 2025-01-12 15:31:43 +08:00
MaysWind 0f8431ff5c remove space decimal separator 2025-01-12 15:29:26 +08:00
MaysWind 659b819011 code refactor 2025-01-12 15:10:22 +08:00
MaysWind 6a62cfdef7 code refactor 2025-01-12 15:10:07 +08:00
MaysWind f2ebd751d4 migrate app and user setting page framework to composition API and typescript 2025-01-12 12:55:45 +08:00
MaysWind 1414f54a12 migrate statistics setting page to composition API and typescript 2025-01-12 12:39:28 +08:00
MaysWind c3265c5bf6 migrate text size setting page to composition API and typescript 2025-01-12 11:45:14 +08:00
MaysWind 13e322bb57 code refactor 2025-01-12 10:58:12 +08:00
MaysWind 5cacfc8daf migrate income&expense overview card and monthly income&expense card to composition API and typescript 2025-01-12 02:10:40 +08:00
MaysWind 9bbe4d2dcf code refactor 2025-01-12 01:04:19 +08:00
MaysWind b517409229 migrate schedule frequency select / selection sheet to composition API and typescript 2025-01-12 01:02:03 +08:00
MaysWind 75a96e871a migrate overview list item selection sheet to composition API and typescript 2025-01-12 00:06:24 +08:00
MaysWind 395f7dfd63 migrate webauthn.js to typescript 2025-01-11 23:49:47 +08:00
MaysWind b166f6ff56 code refactor 2025-01-11 23:10:17 +08:00
MaysWind eb2b6d1002 migrate transaction tag list page to composition API and typescript 2025-01-11 23:09:03 +08:00
MaysWind 6cb045453a migrate transaction tag sheet to composition API and typescript 2025-01-11 20:12:15 +08:00
MaysWind ffae9e81a7 migrate transaction tag store to composition API and typescript 2025-01-11 19:53:09 +08:00
MaysWind dc59d3954a remove unused code 2025-01-11 18:15:10 +08:00
MaysWind 7b26eb50bf fix trend analysis not reload data when set custom month range in mobile version 2025-01-11 18:08:39 +08:00
MaysWind c0ab1ad793 fix the dark mode not take effect in mobile photo browser 2025-01-11 18:06:55 +08:00
MaysWind c73dcb51e4 migrate month range selection sheet / dialog to composition API and typescript 2025-01-11 17:57:14 +08:00
MaysWind 8c7fc0fef9 migrate datetime selection sheet to composition API and typescript 2025-01-11 16:51:56 +08:00
MaysWind 76a2e24d06 migrate datetime select to composition API and typescript 2025-01-11 15:58:09 +08:00
MaysWind 3e6a054913 migrate date range selection sheet / dialog to composition API and typescript 2025-01-11 15:44:54 +08:00
MaysWind 89b233e51b code refactor 2025-01-11 14:05:17 +08:00
MaysWind 61f26e060e code refactor 2025-01-11 02:50:59 +08:00
MaysWind 5649bb243d code refactor 2025-01-11 02:29:44 +08:00
MaysWind ea90e97f92 update unit test 2025-01-11 02:14:49 +08:00
MaysWind 3c624188d1 code refactor 2025-01-11 02:14:35 +08:00
MaysWind 04f373e931 migrate about page to composition API and typescript 2025-01-11 01:56:50 +08:00
MaysWind b2e36a24fd migrate mobile ui utils to typescript 2025-01-11 00:53:22 +08:00
MaysWind 8da3d2aa35 migrate i18n helper.js some code to typescript and migrate vue file to composition API and typescript 2025-01-11 00:49:21 +08:00
MaysWind 25c8b9baf8 migrate overview store to composition API and typescript 2025-01-09 00:22:28 +08:00
MaysWind 1555052e1d migrate index.js to typescript 2025-01-08 22:59:58 +08:00
MaysWind b1fbf91d6e migrate color/icon selection sheet to composition API and typescript 2025-01-08 22:52:13 +08:00
MaysWind 5dfac0c085 package locales/helper.ts to commons.js 2025-01-07 22:07:36 +08:00
MaysWind cb142a65f3 move file 2025-01-07 00:09:28 +08:00
MaysWind b0a9b2366e code refactor 2025-01-06 23:52:54 +08:00
MaysWind ed897d4105 change file name 2025-01-06 23:12:05 +08:00
MaysWind 2b71723ba1 migrate number pad sheet to composition API and typescript 2025-01-06 22:41:55 +08:00
MaysWind 60ba3b7977 format code 2025-01-06 22:03:28 +08:00
MaysWind e0198da52c fix not show selected transaction category / account / tag 2025-01-06 21:58:52 +08:00
MaysWind 6365805715 code refactor 2025-01-06 21:49:49 +08:00
MaysWind 5e7e3696bf update comments 2025-01-06 21:43:50 +08:00
MaysWind 166fae425d code refactor 2025-01-06 21:42:36 +08:00
MaysWind f56bef40d8 code refactor 2025-01-05 23:54:35 +08:00
MaysWind ad1eec7d47 migrate exchange rates store to composition API and typescript 2025-01-05 23:22:59 +08:00
MaysWind 5b241d2547 migrate 2fa auth store to composition API and typescript 2025-01-05 23:01:32 +08:00
MaysWind 92a626fb21 migrate token store to composition API and typescript 2025-01-05 22:56:07 +08:00
MaysWind 5171f23c09 migrate user store to composition API and typescript 2025-01-05 22:54:53 +08:00
MaysWind 6dc0ebcac6 code refactor 2025-01-05 21:39:39 +08:00
MaysWind 49f1f3c86b code refactor 2025-01-05 20:43:19 +08:00
MaysWind 53ab441486 remove unused code 2025-01-05 19:46:22 +08:00
MaysWind 0e422b5a8f migrate settings store to composition API and typescript 2025-01-05 19:45:55 +08:00
MaysWind 4f51480af9 migrate map sheet to composition API and typescript 2025-01-05 17:56:58 +08:00
MaysWind fb8fbbcf70 migrate pin code input sheet to composition API and typescript 2025-01-05 17:46:43 +08:00
MaysWind 5256eff88d code refactor 2025-01-05 17:24:22 +08:00
MaysWind 7c40157cba migrate switch to mobile dialog to composition API and typescript 2025-01-05 17:07:46 +08:00
MaysWind 454e97c9f1 migrate services.js to ts 2025-01-05 17:00:26 +08:00
MaysWind 41d34af4c7 migrate userstat.js to ts 2025-01-05 14:07:47 +08:00
MaysWind a13f5bfb10 support base components folder 2025-01-05 13:53:08 +08:00
MaysWind da06fe4a7b migrate color / icon select to typescript 2025-01-05 13:22:35 +08:00
MaysWind 061ea6aab4 code refactor 2025-01-05 13:14:08 +08:00
MaysWind a19cc81391 code refactor 2025-01-05 13:13:21 +08:00
MaysWind 871164b969 code refactor 2025-01-05 03:12:22 +08:00
MaysWind 16fa77eb09 migrate map code to typescript 2025-01-05 03:12:16 +08:00
MaysWind a46399cbaf migrate setting.js and logger.js to ts 2025-01-04 23:42:57 +08:00
MaysWind a9e50d29d3 fix display incorrect month 2025-01-04 23:34:20 +08:00
MaysWind e7d7f217a9 migrate consts/statistics.js to ts 2025-01-04 23:33:17 +08:00
MaysWind 000c2b9ab0 migrate desktop ui utils to typescript 2025-01-04 20:52:42 +08:00
MaysWind 37fdb161ea migrate information / password input / passcode input sheet to composition API and typescript 2025-01-04 19:29:34 +08:00
MaysWind 2d923bbdc9 migrate confirm dialog to composition API and typescript 2025-01-04 19:29:25 +08:00
MaysWind 07c55de024 migrate steps bar to composition API and typescript 2025-01-04 19:02:37 +08:00
MaysWind 30c463627a code refactor 2025-01-04 19:02:30 +08:00
MaysWind 5eec635146 migrate snack bar to composition API and typescript 2025-01-04 19:02:21 +08:00
MaysWind 229d9c76c3 migrate item icon to composition API and typescript 2025-01-04 17:49:38 +08:00
MaysWind 0119eadc14 code refactor 2025-01-04 17:20:36 +08:00
MaysWind c1c656ab7e add missing type 2025-01-04 17:20:28 +08:00
MaysWind e4a50bcd60 migrate to composition API and typescript 2025-01-04 17:20:00 +08:00
MaysWind d18e6df1c0 pin code input supports pressing enter to confirm and pressing tab to switch to the next component 2025-01-04 16:33:33 +08:00
MaysWind af9aa726f4 migrate pin code input to composition API and typescript 2025-01-04 16:33:18 +08:00
MaysWind 27f8c90dae migrate numeral.js to ts 2025-01-04 14:47:52 +08:00
MaysWind b9a3c384d9 remove unused code 2025-01-04 14:05:24 +08:00
MaysWind eed7085756 code refactor 2025-01-04 14:05:16 +08:00
MaysWind abb0c2ad16 fix cannot click set custom date range in date filter drop menu 2025-01-04 00:48:50 +08:00
MaysWind 9f7b40381c migrate lib/datetime.js to ts 2025-01-04 00:18:35 +08:00
MaysWind ad9a390b58 migrate consts/datetime.js to ts 2025-01-02 00:39:56 +08:00
MaysWind 5525635df1 bump year 2025-01-01 01:43:26 +08:00
MaysWind 863e0205ff code refactor 2024-12-30 23:11:03 +08:00
MaysWind 2560a70e5e migrate to typescript 2024-12-30 00:56:48 +08:00
MaysWind b638a73e4d modify style 2024-12-29 22:46:48 +08:00
MaysWind 0a9cea4df3 do not check whether the date range is billing cycle when no statement date 2024-12-23 08:50:24 +08:00
MaysWind 15a98c3eac add missing router path 2024-12-22 22:05:05 +08:00
MaysWind 493c16087d add missing router path 2024-12-22 21:52:56 +08:00
MaysWind dd155a0f63 add gitea deploy action 2024-12-22 17:21:53 +08:00
MaysWind 9ce1c8d397 upgrade third party dependencies 2024-12-22 12:09:06 +08:00
MaysWind 3040435c06 upgrade third party dependencies 2024-12-22 11:48:34 +08:00
MaysWind 685f93f05e upgrade node.js to 22.12.0 2024-12-22 11:34:23 +08:00
MaysWind d465d9da1a add sample case for not showing currency option 2024-12-22 11:25:23 +08:00
MaysWind 50c2766014 format file 2024-12-22 00:09:17 +08:00
MaysWind 4e1cbf13c6 bump version to 0.8.0 2024-12-21 23:57:06 +08:00
381 changed files with 55595 additions and 38616 deletions
+17
View File
@@ -0,0 +1,17 @@
name: Deploy Docker Image
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Execute custom script
run: |
cat >> deploy.sh <<EOF
#!/bin/sh
${{ vars.CUSTOM_DEPLOY_SCRIPTS }}
EOF
chmod +x deploy.sh
./deploy.sh
+8 -5
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -24,10 +24,12 @@ jobs:
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Set up the environment
run: |
@@ -44,7 +46,7 @@ jobs:
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
@@ -52,5 +54,6 @@ jobs:
push: true
build-args: |
RELEASE_BUILD=1
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7 -5
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
@@ -24,10 +24,12 @@ jobs:
type=sha,format=short,prefix=SNAPSHOT-
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Set up the environment
run: |
@@ -44,7 +46,7 @@ jobs:
chmod +x docker/custom-frontend-pre-setup.sh
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
+10 -7
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
@@ -24,19 +24,21 @@ jobs:
type=raw,value=latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
@@ -48,5 +50,6 @@ jobs:
push: true
build-args: |
RELEASE_BUILD=1
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+9 -7
View File
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
@@ -23,19 +23,21 @@ jobs:
type=raw,value=latest-snapshot
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
with:
image: tonistiigi/binfmt:qemu-v8.1.5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
@@ -46,6 +48,6 @@ jobs:
linux/arm/v6
push: true
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+4 -4
View File
@@ -11,20 +11,20 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
file: Dockerfile
context: .
platforms: linux/amd64
push: false
build-args: |
SKIP_TESTS=${{ vars.SKIP_TESTS }}
SKIP_TESTS=${{ vars.SKIP_TESTS }}
+3 -3
View File
@@ -1,5 +1,5 @@
# Build backend binary file
FROM golang:1.23.4-alpine3.21 AS be-builder
FROM golang:1.24.0-alpine3.21 AS be-builder
ARG RELEASE_BUILD
ARG SKIP_TESTS
ENV RELEASE_BUILD=$RELEASE_BUILD
@@ -11,7 +11,7 @@ RUN apk add git gcc g++ libc-dev
RUN ./build.sh backend
# Build frontend files
FROM --platform=$BUILDPLATFORM node:20.18.1-alpine3.21 AS fe-builder
FROM --platform=$BUILDPLATFORM node:22.14.0-alpine3.21 AS fe-builder
ARG RELEASE_BUILD
ENV RELEASE_BUILD=$RELEASE_BUILD
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
@@ -21,7 +21,7 @@ RUN apk add git
RUN ./build.sh frontend
# Package docker image
FROM alpine:3.21.0
FROM alpine:3.21.3
LABEL maintainer="MaysWind <i@mayswind.net>"
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
RUN apk --no-cache add tzdata
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2024 MaysWind (i@mayswind.net)
Copyright (c) 2020-2025 MaysWind (i@mayswind.net)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+3 -3
View File
@@ -6,7 +6,7 @@
[![Latest Release](https://img.shields.io/github/release/mayswind/ezbookkeeping.svg?style=flat)](https://github.com/mayswind/ezbookkeeping/releases)
## Introduction
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including sqlite and mysql. With docker, you can just deploy it via one command without complicated configuration.
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including SQLite, MySQL and PostgreSQL. With docker, you can just deploy it via one command without complicated configuration.
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
@@ -31,7 +31,7 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem
7. Multi-language support
8. Two-factor authentication
9. Application lock (PIN code / WebAuthn)
10. Data import & export
10. Data export & import (OFX, QFX, QIF, IIF, CSV, GnuCash, FireFly III, etc.)
## Screenshots
### Desktop Version
@@ -88,7 +88,7 @@ You can also build docker image, make sure you have [docker](https://www.docker.
## Documents
1. [English](http://ezbookkeeping.mayswind.net)
1. [简体中文 (Simplified Chinese)](http://ezbookkeeping.mayswind.net/zh_Hans)
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
## License
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
+5 -1
View File
@@ -792,7 +792,11 @@ func exportUserTransaction(c *core.CliContext) error {
filePath := c.String("file")
fileType := c.String("type")
if fileType != "" && fileType != "csv" && fileType != "tsv" {
if fileType == "" {
fileType = "csv"
}
if fileType != "csv" && fileType != "tsv" {
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
return errs.ErrNotSupported
}
+6
View File
@@ -116,8 +116,13 @@ func startWebServer(c *core.CliContext) error {
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
router.StaticFile("sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
for i := 0; i < len(workboxFileNames); i++ {
router.StaticFile("/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
}
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
@@ -310,6 +315,7 @@ func startWebServer(c *core.CliContext) error {
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
if config.EnableDataImport {
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
}
+9 -3
View File
@@ -180,6 +180,12 @@ email_verify_token_expired_time = 3600
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
password_reset_token_expired_time = 3600
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_ip_per_minute = 5
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
max_failures_per_user_per_minute = 5
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
@@ -248,7 +254,7 @@ enable_tips_in_login_page = false
# The custom tips displayed in login page, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
# For example, login_page_tips_content_zh_hans means the notification content in Simplified Chinese
# For example, login_page_tips_content_zh_hans means the notification content in Chinese (Simplified)
login_page_tips_content =
[notification]
@@ -257,7 +263,7 @@ enable_notification_after_register = false
# The notification content displayed each time users register, it supports multi-language configuration
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
# For example, after_login_notification_content_zh_hans means the notification content in Simplified Chinese
# For example, after_login_notification_content_zh_hans means the notification content in Chinese (Simplified)
after_register_notification_content =
# Set to true to display custom notification in home page every time users login
@@ -315,7 +321,7 @@ amap_application_key =
# "external_proxy": use an external proxy to request amap api (amap application secret should be set by external proxy)
# "plain_text": append amap application secret to frontend request directly (insecurity for public network)
# Please visit https://developer.amap.com/api/jsapi-v2/guide/abc/load for more information
amap_security_verification_method = plain_text
amap_security_verification_method = internal_proxy
# For "amap" map provider only, Amap JavaScript API application secret, this setting must be provided when "amap_security_verification_method" is set to "internal_proxy" or "plain_text", please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
amap_application_secret =
+27 -32
View File
@@ -1,36 +1,31 @@
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import vueTsEslintConfig from '@vue/eslint-config-typescript';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import { includeIgnoreFile } from '@eslint/compat';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const gitignorePath = path.resolve(__dirname, '.gitignore');
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [...compat.extends('eslint:recommended', 'plugin:vue/vue3-essential'),
includeIgnoreFile(gitignorePath), {
languageOptions: {
globals: {
...globals.node,
export default [
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
}
},
},
files: [
"**/*.{vue,js,jsx,cjs,mjs}"
],
rules: {
'vue/no-use-v-if-with-v-for': 'off',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
{
ignores: [
'dist/**',
'**/*.{js,jsx,cjs,mjs}'
]
},
}];
{
files: [
'**/*.{vue,ts,tsx,mts,js,jsx,cjs,mjs}'
],
rules: {
'vue/valid-v-slot': ['error', {
allowModifiers: true
}]
}
},
];
+23 -23
View File
@@ -1,29 +1,29 @@
module github.com/mayswind/ezbookkeeping
go 1.22
go 1.24
require (
github.com/boombuler/barcode v1.0.2
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
github.com/gin-contrib/cache v1.3.0
github.com/gin-contrib/gzip v1.0.1
github.com/gin-contrib/cache v1.3.1
github.com/gin-contrib/gzip v1.2.2
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.12.3
github.com/go-playground/validator/v10 v10.22.1
github.com/go-co-op/gocron/v2 v2.15.0
github.com/go-playground/validator/v10 v10.24.0
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/minio/minio-go/v7 v7.0.80
github.com/minio/minio-go/v7 v7.0.85
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.4.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.4
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.5
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/text v0.19.0
golang.org/x/crypto v0.33.0
golang.org/x/net v0.34.0
golang.org/x/text v0.22.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/mail.v2 v2.3.1
xorm.io/builder v0.3.13
@@ -35,30 +35,30 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/bytedance/sonic v1.12.7 // indirect
github.com/bytedance/sonic/loader v0.2.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/gomodule/redigo v1.9.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -66,7 +66,7 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
@@ -77,10 +77,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
golang.org/x/sys v0.30.0 // indirect
google.golang.org/protobuf v1.36.2 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+44 -45
View File
@@ -12,10 +12,11 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
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.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o=
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
@@ -23,10 +24,9 @@ github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -39,38 +39,38 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
github.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.12.3 h1:3JkKjkFoAPp/i0YE+sonlF5gi+xnBChwYh75nX16MaE=
github.com/go-co-op/gocron/v2 v2.12.3/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -84,8 +84,8 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
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.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -104,8 +104,8 @@ github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo=
github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -116,8 +116,8 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
@@ -142,8 +142,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
@@ -152,34 +152,33 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
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.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2470 -1226
View File
File diff suppressed because it is too large Load Diff
+29 -23
View File
@@ -1,6 +1,6 @@
{
"name": "ezbookkeeping",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"repository": {
"type": "git",
@@ -15,52 +15,58 @@
"serve": "cross-env NODE_ENV=development vite",
"build": "cross-env NODE_ENV=production vite build",
"serve:dist": "vite preview",
"lint": "eslint . --fix"
"lint": "tsc --noEmit && eslint . --fix"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@vuepic/vue-datepicker": "^10.0.0",
"axios": "^1.7.7",
"@vuepic/vue-datepicker": "^11.0.1",
"axios": "^1.7.9",
"cbor-js": "^0.1.0",
"clipboard": "^2.0.11",
"crypto-js": "^4.2.0",
"dom7": "^4.0.6",
"echarts": "^5.5.1",
"echarts": "^5.6.0",
"framework7": "^8.3.4",
"framework7-icons": "^5.0.5",
"framework7-vue": "^8.3.4",
"leaflet": "^1.9.4",
"line-awesome": "^1.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"pinia": "^2.2.5",
"moment-timezone": "^0.5.47",
"pinia": "^2.3.1",
"register-service-worker": "^1.7.2",
"skeleton-elements": "^4.0.1",
"swiper": "^10.2.0",
"ua-parser-js": "^1.0.39",
"vue": "^3.5.12",
"vue": "^3.5.13",
"vue-echarts": "^7.0.3",
"vue-i18n": "^10.0.4",
"vue-router": "^4.4.5",
"vue-i18n": "^11.1.1",
"vue-router": "^4.5.0",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.7.3"
"vuetify": "^3.7.11"
},
"devDependencies": {
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.1.4",
"@tsconfig/node22": "^22.0.0",
"@types/cbor-js": "^0.1.1",
"@types/crypto-js": "^4.2.2",
"@types/git-rev-sync": "^2.0.2",
"@types/node": "^22.12.0",
"@types/ua-parser-js": "^0.7.39",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^14.3.0",
"@vue/tsconfig": "^0.7.0",
"cross-env": "^7.0.3",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"eslint": "^9.20.0",
"eslint-plugin-vue": "^9.32.0",
"git-rev-sync": "^3.0.2",
"globals": "^15.11.0",
"postcss-preset-env": "^10.0.9",
"sass": "^1.80.6",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5",
"vite-plugin-vuetify": "^2.0.4"
"postcss-preset-env": "^10.1.3",
"sass": "^1.84.0",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-vuetify": "^2.1.0",
"vue-tsc": "^2.2.0"
},
"browserslist": [
"> 1%",
+3
View File
@@ -28,6 +28,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
accounts: services.Accounts,
+56 -1
View File
@@ -5,6 +5,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
@@ -15,6 +16,7 @@ import (
// AuthorizationsApi represents authorization api
type AuthorizationsApi struct {
ApiUsingConfig
ApiUsingDuplicateChecker
ApiWithUserInfo
users *services.UserService
tokens *services.TokenService
@@ -27,6 +29,12 @@ var (
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
ApiWithUserInfo: ApiWithUserInfo{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
@@ -51,7 +59,23 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
return nil, errs.ErrLoginNameOrPasswordInvalid
}
user, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
err = a.CheckFailureCount(c, 0)
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
user, uid, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
if errs.IsCustomError(err) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
}
if err != nil {
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
@@ -133,6 +157,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
}
uid := c.GetCurrentUid()
err = a.CheckFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
if err != nil {
@@ -142,6 +173,14 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
err = a.CheckAndIncreaseFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
return nil, errs.ErrPasscodeInvalid
}
@@ -196,6 +235,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
}
uid := c.GetCurrentUid()
err = a.CheckFailureCount(c, uid)
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
}
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
if err != nil {
@@ -226,6 +272,15 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
if errs.IsCustomError(err) {
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
if failureCheckErr != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, failureCheckErr.Error())
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
}
}
if err != nil {
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist)
+66
View File
@@ -5,9 +5,13 @@ import (
"sort"
"github.com/mayswind/ezbookkeeping/pkg/avatars"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
@@ -100,6 +104,7 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
type ApiUsingDuplicateChecker struct {
ApiUsingConfig
container *duplicatechecker.DuplicateCheckerContainer
}
@@ -113,6 +118,67 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemark(checkerType duplicatechec
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
}
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
func (a *ApiUsingDuplicateChecker) CheckFailureCount(c *core.WebContext, uid int64) error {
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
clientIp := c.ClientIP()
ipFailureCount := a.container.GetFailureCount(clientIp)
if ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
return errs.ErrFailureCountLimitReached
}
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
uidFailureCount := a.container.GetFailureCount(utils.Int64ToString(uid))
if uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
return errs.ErrFailureCountLimitReached
}
}
return nil
}
// CheckAndIncreaseFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
func (a *ApiUsingDuplicateChecker) CheckAndIncreaseFailureCount(c *core.WebContext, uid int64) error {
clientIp := c.ClientIP()
ipFailureCount := uint32(0)
uidFailureCount := uint32(0)
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
ipFailureCount = a.container.GetFailureCount(clientIp)
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
uidFailureCount = a.container.GetFailureCount(utils.Int64ToString(uid))
}
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount < a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", previous failure count: %d", clientIp, ipFailureCount)
a.container.IncreaseFailureCount(clientIp)
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount < a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", previous failure count: %d", uid, uidFailureCount)
a.container.IncreaseFailureCount(utils.Int64ToString(uid))
}
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
return errs.ErrFailureCountLimitReached
}
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
return errs.ErrFailureCountLimitReached
}
return nil
}
// ApiUsingAvatarProvider represents an api that need to use avatar provider
type ApiUsingAvatarProvider struct {
container *avatars.AvatarProviderContainer
+6 -1
View File
@@ -66,7 +66,12 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *
for i := 0; i < len(requests); i++ {
req := requests[i]
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s ", settings.Version))
if len(req.Header.Values("User-Agent")) < 1 {
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version))
} else if req.Header.Get("User-Agent") == "" {
req.Header.Del("User-Agent")
}
resp, err := client.Do(req)
+1 -1
View File
@@ -23,7 +23,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSou
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
supportedCurrencyCodes := []string{"CAD", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
supportedCurrencyCodes := []string{"CAD", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
"MYR", "NZD", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
+3
View File
@@ -29,6 +29,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
categories: services.TransactionCategories,
+3
View File
@@ -26,6 +26,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
users: services.Users,
+67 -3
View File
@@ -31,6 +31,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
templates: services.TransactionTemplates,
@@ -156,7 +159,12 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
}
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
template := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
@@ -260,6 +268,34 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
if templateModifyReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
startUnixTime := startTime.Unix()
newTemplate.ScheduledStartTime = &startUnixTime
}
if templateModifyReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
if err != nil {
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
endUnixTime := endTime.Unix()
newTemplate.ScheduledEndTime = &endUnixTime
}
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
if newTemplate.Name == template.Name &&
@@ -277,6 +313,8 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
newTemplate.ScheduledAt == template.ScheduledAt &&
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
return nil, errs.ErrNothingWillBeUpdated
@@ -419,7 +457,7 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
return true, nil
}
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
template := &models.TransactionTemplate{
Uid: uid,
TemplateType: templateCreateReq.TemplateType,
@@ -441,9 +479,35 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
if templateCreateReq.ScheduledStartDate != nil {
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
startUnixTime := startTime.Unix()
template.ScheduledStartTime = &startUnixTime
}
if templateCreateReq.ScheduledEndDate != nil {
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
if err != nil {
return nil, err
}
endUnixTime := endTime.Unix()
template.ScheduledEndTime = &endUnixTime
}
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
}
}
return template
return template, nil
}
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
+164 -3
View File
@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"io"
"sort"
"strings"
@@ -8,6 +9,8 @@ import (
orderedmap "github.com/wk8/go-ordered-map/v2"
"github.com/mayswind/ezbookkeeping/pkg/converters"
baseconverters "github.com/mayswind/ezbookkeeping/pkg/converters/base"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
"github.com/mayswind/ezbookkeeping/pkg/errs"
@@ -40,6 +43,9 @@ var (
container: settings.Container,
},
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
ApiUsingConfig: ApiUsingConfig{
container: settings.Container,
},
container: duplicatechecker.Container,
},
transactions: services.Transactions,
@@ -382,10 +388,10 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
return nil, errs.Or(err, errs.ErrOperationFailed)
}
statisticTrendsResp := make(models.TransactionStatisticTrendsItemSlice, 0, len(allMonthlyTotalAmounts))
statisticTrendsResp := make(models.TransactionStatisticTrendsResponseItemSlice, 0, len(allMonthlyTotalAmounts))
for yearMonth, monthlyTotalAmounts := range allMonthlyTotalAmounts {
monthlyStatisticResp := &models.TransactionStatisticTrendsItem{
monthlyStatisticResp := &models.TransactionStatisticTrendsResponseItem{
Year: yearMonth / 100,
Month: yearMonth % 100,
Items: make([]*models.TransactionStatisticResponseItem, len(monthlyTotalAmounts)),
@@ -1030,6 +1036,83 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
return true, nil
}
// TransactionParseImportDsvFileDataHandler returns the parsed file data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
form, err := c.MultipartForm()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrParameterInvalid
}
fileTypes := form.Value["fileType"]
if len(fileTypes) < 1 || fileTypes[0] == "" {
return nil, errs.ErrImportFileTypeIsEmpty
}
fileType := fileTypes[0]
if !converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
}
fileEncodings := form.Value["fileEncoding"]
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
return nil, errs.ErrImportFileEncodingIsEmpty
}
fileEncoding := fileEncodings[0]
dataParser, err := converters.CreateNewDelimiterSeparatedValuesDataParser(fileType, fileEncoding)
if err != nil {
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
}
importFiles := form.File["file"]
if len(importFiles) < 1 {
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] there is no import file in request for user \"uid:%d\"", uid)
return nil, errs.ErrNoFilesUpload
}
if importFiles[0].Size < 1 {
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
return nil, errs.ErrUploadedFileEmpty
}
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
return nil, errs.ErrExceedMaxUploadFileSize
}
importFile, err := importFiles[0].Open()
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrOperationFailed
}
defer importFile.Close()
fileData, err := io.ReadAll(importFile)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
allLines, err := dataParser.ParseDsvFileLines(c, fileData)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return allLines, nil
}
// TransactionParseImportFileHandler returns the parsed transaction data by request parameters for current user
func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) (any, *errs.Error) {
uid := c.GetCurrentUid()
@@ -1054,7 +1137,84 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
}
fileType := fileTypes[0]
dataImporter, err := converters.GetTransactionDataImporter(fileType)
var dataImporter baseconverters.TransactionDataImporter
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
fileEncodings := form.Value["fileEncoding"]
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
return nil, errs.ErrImportFileEncodingIsEmpty
}
fileEncoding := fileEncodings[0]
columnMappings := form.Value["columnMapping"]
if len(columnMappings) < 1 || columnMappings[0] == "" {
return nil, errs.ErrImportFileColumnMappingInvalid
}
var columnIndexMapping = map[datatable.TransactionDataTableColumn]int{}
err = json.Unmarshal([]byte(columnMappings[0]), &columnIndexMapping)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse column mapping for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrImportFileColumnMappingInvalid
}
transactionTypeMappings := form.Value["transactionTypeMapping"]
if len(transactionTypeMappings) < 1 || transactionTypeMappings[0] == "" {
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
}
var transactionTypeNameMapping = map[string]models.TransactionType{}
err = json.Unmarshal([]byte(transactionTypeMappings[0]), &transactionTypeNameMapping)
if err != nil {
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse transaction type mapping for user \"uid:%d\", because %s", uid, err.Error())
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
}
hasHeaderLines := form.Value["hasHeaderLine"]
hasHeaderLine := false
if len(hasHeaderLines) > 0 {
hasHeaderLine = hasHeaderLines[0] == "true"
}
timeFormats := form.Value["timeFormat"]
if len(timeFormats) < 1 || timeFormats[0] == "" {
return nil, errs.ErrImportFileTransactionTimeFormatInvalid
}
timezoneFormats := form.Value["timezoneFormat"]
timezoneFormat := ""
if len(timezoneFormats) > 0 {
timezoneFormat = timezoneFormats[0]
}
geoLocationSeparators := form.Value["geoSeparator"]
geoLocationSeparator := ""
if len(geoLocationSeparators) > 0 {
geoLocationSeparator = geoLocationSeparators[0]
}
transactionTagSeparators := form.Value["tagSeparator"]
transactionTagSeparator := ""
if len(transactionTagSeparators) > 0 {
transactionTagSeparator = transactionTagSeparators[0]
}
dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, geoLocationSeparator, transactionTagSeparator)
} else {
dataImporter, err = converters.GetTransactionDataImporter(fileType)
}
if err != nil {
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
@@ -1084,6 +1244,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
return nil, errs.ErrOperationFailed
}
defer importFile.Close()
fileData, err := io.ReadAll(importFile)
if err != nil {
@@ -418,7 +418,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
geoLongitude := float64(0)
geoLatitude := float64(0)
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) {
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) && c.geoLocationSeparator != "" {
geoLocationItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), c.geoLocationSeparator)
if len(geoLocationItems) == 2 {
@@ -442,7 +442,13 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
var tagNames []string
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS) {
tagNameItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
var tagNameItems []string
if c.transactionTagSeparator != "" {
tagNameItems = strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
} else {
tagNameItems = append(tagNameItems, dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS))
}
for i := 0; i < len(tagNameItems); i++ {
tagName := tagNameItems[i]
@@ -0,0 +1,227 @@
package dsv
import (
"bytes"
"encoding/csv"
"io"
"strings"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"github.com/mayswind/ezbookkeeping/pkg/converters/base"
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
var supportedFileTypeSeparators = map[string]rune{
"custom_csv": ',',
"custom_tsv": '\t',
}
var supportedFileEncodings = map[string]encoding.Encoding{
"utf-8": unicode.UTF8, // UTF-8
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), // UTF-16 Little Endian
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM), // UTF-16 Big Endian
"cp437": charmap.CodePage437, // OEM United States (CP-437)
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
"cp850": charmap.CodePage850, // Western European (CP-850)
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
"cp865": charmap.CodePage865, // North European (CP-865)
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
"cp852": charmap.CodePage852, // Central European (CP-852)
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
"cp860": charmap.CodePage860, // Portuguese (CP-860)
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
"cp862": charmap.CodePage862, // Hebrew (CP-862)
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
"windows-874": charmap.Windows874, // Thai (Windows-874)
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
}
var customTransactionTypeNameMapping = map[models.TransactionType]string{
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
}
type CustomTransactionDataDsvFileParser interface {
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
}
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
type customTransactionDataDsvFileImporter struct {
fileEncoding encoding.Encoding
separator rune
columnIndexMapping map[datatable.TransactionDataTableColumn]int
transactionTypeNameMapping map[string]models.TransactionType
hasHeaderLine bool
timeFormat string
timezoneFormat string
geoLocationSeparator string
transactionTagSeparator string
}
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
csvReader := csv.NewReader(reader)
csvReader.Comma = c.separator
csvReader.FieldsPerRecord = -1
allLines := make([][]string, 0)
for {
items, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
return nil, errs.ErrInvalidCSVFile
}
if len(items) == 1 && items[0] == "" {
continue
}
for index := range items {
items[index] = strings.Trim(items[index], " ")
}
allLines = append(allLines, items)
}
return allLines, nil
}
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
allLines, err := c.ParseDsvFileLines(ctx, data)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
if !c.hasHeaderLine {
allLines = append([][]string{{}}, allLines...)
}
dataTable := csvconverter.CreateNewCustomCsvImportedDataTable(allLines)
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat)
dataTableImporter := datatable.CreateNewImporter(customTransactionTypeNameMapping, c.geoLocationSeparator, c.transactionTagSeparator)
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
}
// IsDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
func IsDelimiterSeparatedValuesFileType(fileType string) bool {
_, exists := supportedFileTypeSeparators[fileType]
return exists
}
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
separator, exists := supportedFileTypeSeparators[fileType]
if !exists {
return nil, errs.ErrImportFileTypeNotSupported
}
enc, exists := supportedFileEncodings[fileEncoding]
if !exists {
return nil, errs.ErrImportFileEncodingNotSupported
}
return &customTransactionDataDsvFileImporter{
fileEncoding: enc,
separator: separator,
}, nil
}
// CreateNewCustomTransactionDataDsvFileImporter returns a new custom dsv importer for transaction data
func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (base.TransactionDataImporter, error) {
separator, exists := supportedFileTypeSeparators[fileType]
if !exists {
return nil, errs.ErrImportFileTypeNotSupported
}
enc, exists := supportedFileEncodings[fileEncoding]
if !exists {
return nil, errs.ErrImportFileEncodingNotSupported
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
if _, exists = columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
return nil, errs.ErrMissingRequiredFieldInHeaderRow
}
return &customTransactionDataDsvFileImporter{
fileEncoding: enc,
separator: separator,
columnIndexMapping: columnIndexMapping,
transactionTypeNameMapping: transactionTypeNameMapping,
hasHeaderLine: hasHeaderLine,
timeFormat: timeFormat,
timezoneFormat: timezoneFormat,
geoLocationSeparator: geoLocationSeparator,
transactionTagSeparator: transactionTagSeparator,
}, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
package dsv
import (
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// customPlainTextDataTable defines the structure of custom plain text transaction data table
type customPlainTextDataTable struct {
innerDataTable datatable.ImportedDataTable
columnIndexMapping map[datatable.TransactionDataTableColumn]int
transactionTypeNameMapping map[string]models.TransactionType
timeFormat string
timezoneFormat string
timeFormatIncludeTimezone bool
}
// customPlainTextDataRow defines the structure of custom plain text transaction data row
type customPlainTextDataRow struct {
transactionDataTable *customPlainTextDataTable
rowData map[datatable.TransactionDataTableColumn]string
isValid bool
}
// customPlainTextDataRowIterator defines the structure of custom plain text transaction data row iterator
type customPlainTextDataRowIterator struct {
transactionDataTable *customPlainTextDataTable
innerIterator datatable.ImportedDataRowIterator
}
// HasColumn returns whether the data table has specified column
func (t *customPlainTextDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
// custom dsv file allows no sub category, account name and related account name column mapping, but data table converter needs these columns
if column == datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY ||
column == datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME ||
column == datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME {
return true
}
// timezone column will be added when original time format contains timezone
if t.timeFormatIncludeTimezone && column == datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE {
return true
}
_, exists := t.columnIndexMapping[column]
return exists
}
// TransactionRowCount returns the total count of transaction data row
func (t *customPlainTextDataTable) TransactionRowCount() int {
return t.innerDataTable.DataRowCount()
}
// TransactionRowIterator returns the iterator of transaction data row
func (t *customPlainTextDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
return &customPlainTextDataRowIterator{
transactionDataTable: t,
innerIterator: t.innerDataTable.DataRowIterator(),
}
}
// IsValid returns whether this row is valid data for importing
func (r *customPlainTextDataRow) IsValid() bool {
return r.isValid
}
// GetData returns the data in the specified column type
func (r *customPlainTextDataRow) GetData(column datatable.TransactionDataTableColumn) string {
return r.rowData[column]
}
// HasNext returns whether the iterator does not reach the end
func (t *customPlainTextDataRowIterator) HasNext() bool {
return t.innerIterator.HasNext()
}
// Next returns the next transaction data row
func (t *customPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
importedRow := t.innerIterator.Next()
if importedRow == nil {
return nil, nil
}
rowData, isValid, err := t.parseTransaction(ctx, user, importedRow)
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.Next] cannot parsing transaction in row \"%s\", because %s", t.innerIterator.CurrentRowId(), err.Error())
return nil, err
}
return &customPlainTextDataRow{
transactionDataTable: t.transactionDataTable,
rowData: rowData,
isValid: isValid,
}, nil
}
func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user *models.User, row datatable.ImportedDataRow) (map[datatable.TransactionDataTableColumn]string, bool, error) {
rowData := make(map[datatable.TransactionDataTableColumn]string, len(t.transactionDataTable.columnIndexMapping))
for column, columnIndex := range t.transactionDataTable.columnIndexMapping {
if columnIndex < 0 || columnIndex >= row.ColumnCount() {
continue
}
value := row.GetData(columnIndex)
rowData[column] = value
}
// parse transaction type
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != "" {
transactionType, exists := t.transactionDataTable.transactionTypeNameMapping[rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]]
if !exists {
log.Warnf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] skip parsing this transaction, because transaction type \"%s\" mapping not defined", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE])
return nil, false, nil
}
mappedTransactionType, exists := customTransactionTypeNameMapping[transactionType]
if !exists {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction type \"%s\", because type \"%d\" is invalid", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE], transactionType)
return nil, false, errs.ErrTransactionTypeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = mappedTransactionType
}
// parse date time
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
dateTime, err := time.Parse(t.transactionDataTable.timeFormat, rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
if err != nil {
return nil, false, errs.ErrTransactionTimeInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
if t.transactionDataTable.timeFormatIncludeTimezone {
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
}
}
// parse timezone
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] != "" {
if t.transactionDataTable.timezoneFormat == "Z" || t.transactionDataTable.timezoneFormat == "" { // -HH:mm
// Do Nothing
} else if t.transactionDataTable.timezoneFormat == "ZZ" { // -HHmm
timezone := rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE]
if len(timezone) != 5 {
return nil, false, errs.ErrTransactionTimeZoneInvalid
}
timezone = timezone[:3] + ":" + timezone[3:]
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
} else {
return nil, false, errs.ErrImportFileTransactionTimezoneFormatInvalid
}
}
// use primary category if sub category is empty
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY]
}
// trim trailing zero in decimal
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT], err.Error())
return nil, false, errs.ErrAmountInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
}
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
if err != nil {
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction related amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT], err.Error())
return nil, false, errs.ErrAmountInvalid
}
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount)
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
}
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]; !exists {
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
}
return rowData, true, nil
}
// CreateNewCustomPlainTextDataTable returns transaction data table from imported data table
func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string) *customPlainTextDataTable {
timeFormatIncludeTimezone := strings.Contains(timeFormat, "z") || strings.Contains(timeFormat, "Z")
return &customPlainTextDataTable{
innerDataTable: dataTable,
columnIndexMapping: columnIndexMapping,
transactionTypeNameMapping: transactionTypeNameMapping,
timeFormat: getDateTimeFormat(timeFormat),
timezoneFormat: timezoneFormat,
timeFormatIncludeTimezone: timeFormatIncludeTimezone,
}
}
func getDateTimeFormat(format string) string {
// convert moment.js format to Go format
format = strings.ReplaceAll(format, "YYYY", "2006")
format = strings.ReplaceAll(format, "YY", "06")
format = strings.ReplaceAll(format, "MMMM", "January")
format = strings.ReplaceAll(format, "MMM", "Jan")
format = strings.ReplaceAll(format, "MM", "01")
format = strings.ReplaceAll(format, "M", "1")
format = strings.ReplaceAll(format, "DD", "02")
format = strings.ReplaceAll(format, "D", "2")
format = strings.ReplaceAll(format, "dddd", "Monday")
format = strings.ReplaceAll(format, "ddd", "Mon")
format = strings.ReplaceAll(format, "HH", "15")
format = strings.ReplaceAll(format, "H", "15")
format = strings.ReplaceAll(format, "hh", "03")
format = strings.ReplaceAll(format, "h", "3")
format = strings.ReplaceAll(format, "mm", "04")
format = strings.ReplaceAll(format, "m", "4")
format = strings.ReplaceAll(format, "ss", "05")
format = strings.ReplaceAll(format, "s", "5")
for i := 9; i >= 1; i-- {
format = strings.ReplaceAll(format, "."+strings.Repeat("S", i), "."+strings.Repeat("9", i))
}
format = strings.ReplaceAll(format, "A", "PM")
format = strings.ReplaceAll(format, "a", "pm")
format = strings.ReplaceAll(format, "zz", "MST")
format = strings.ReplaceAll(format, "z", "MST")
if strings.Contains(format, "ZZ") {
format = strings.ReplaceAll(format, "ZZ", "Z0700")
} else if strings.Contains(format, "Z") {
format = strings.ReplaceAll(format, "Z", "Z07:00")
}
return format
}
@@ -98,6 +98,7 @@ func (t *gnucashTransactionDataRowIterator) Next(ctx core.Context, user *models.
rowItems, isValid, err := t.parseTransaction(ctx, user, data)
if err != nil {
log.Errorf(ctx, "[gnucash_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
return nil, err
}
+16 -1
View File
@@ -129,11 +129,26 @@ func (r *iifDataReader) read(ctx core.Context) ([]*iifAccountDataset, []*iifTran
}
} else if lastLineSign == iifTransactionLineSignColumnName || lastLineSign == iifTransactionSplitLineSignColumnName {
if items[0] == iifTransactionSplitLineSignColumnName {
if currentTransactionData == nil {
log.Errorf(ctx, "[iif_data_reader.read] expected current transaction data is not nil, but read \"%s\"", items[0])
return nil, nil, errs.ErrInvalidIIFFile
}
currentTransactionData.splitData = append(currentTransactionData.splitData, &iifTransactionSplitData{
dataItems: items,
})
lastLineSign = items[0]
} else if items[0] == iifTransactionEndLineSignColumnName {
if currentTransactionData == nil {
log.Errorf(ctx, "[iif_data_reader.read] expected current transaction data is not nil, but read \"%s\"", items[0])
return nil, nil, errs.ErrInvalidIIFFile
}
if len(currentTransactionData.splitData) < 1 {
log.Errorf(ctx, "[iif_data_reader.read] expected reading transaction split line, but read \"%s\"", items[0])
return nil, nil, errs.ErrInvalidIIFFile
}
currentTransactionDataset.transactions = append(currentTransactionDataset.transactions, currentTransactionData)
lastLineSign = ""
} else {
@@ -214,7 +229,7 @@ func (r *iifDataReader) readTransactionSampleLines(ctx core.Context, items []str
}
if len(transactionEndSampleItems) < 1 || transactionEndSampleItems[0] != iifTransactionEndSampleLineSignColumnName {
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read \"%s\"", strings.Join(splitSampleItems, "\t"))
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read \"%s\"", strings.Join(transactionEndSampleItems, "\t"))
return nil, errs.ErrInvalidIIFFile
}
@@ -333,7 +333,7 @@ func TestIIFTransactionDataFileParseImportedData_ParseYearMonthDayFormatTime(t *
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
}
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(t *testing.T) {
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayYearFormatTime(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
@@ -364,6 +364,37 @@ func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(t
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
}
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayTwoDigitsYearFormatTime(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t9/01/24\tTest Account\t123.45\n"+
"SPL\t9/01/24\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/2/24\tTest Account\t123.45\n"+
"SPL\t09/2/24\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t9/3/24\tTest Account\t123.45\n"+
"SPL\t9/3/24\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
}
func TestIIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
@@ -377,8 +408,8 @@ func TestIIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T)
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t9/1/24\tTest Account\t123.45\n"+
"SPL\t9/1/24\tTest Account2\t-123.45\n"+
"TRNS\t09-01-2024\tTest Account\t123.45\n"+
"SPL\t09-01-2024\tTest Account2\t-123.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
@@ -486,7 +517,7 @@ func TestIIFTransactionDataFileParseImportedData_ParseDescription(t *testing.T)
assert.Equal(t, "Test", allNewTransactions[0].Comment)
}
func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
func TestIIFTransactionDataFileParseImportedData_ParseSplitTransaction(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
@@ -495,11 +526,218 @@ func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransac
DefaultCurrency: "CNY",
}
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!ACCNT\tNAME\tACCNTTYPE\n"+
"ACCNT\tTest Category\tINC\n"+
"ACCNT\tTest Category2\tEXP\n"+
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\t123.45\n"+
"SPL\t09/01/2024\tTest Category\t-23.45\n"+
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/02/2024\tTest Account\t-100.00\n"+
"SPL\t09/02/2024\tTest Category2\t30.00\n"+
"SPL\t09/02/2024\tTest Account3\t20.00\n"+
"SPL\t09/02/2024\tTest Account4\t50.00\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/03/2024\tTest Account\t100.00\n"+
"SPL\t09/03/2024\tTest Account2\t-100.00\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/04/2024\tTest Category\t-100.00\n"+
"SPL\t09/04/2024\tTest Account\t40.00\n"+
"SPL\t09/04/2024\tTest Account2\t60.00\n"+
"ENDTRNS\t\t\t\n"+
"TRNS\t09/05/2024\tTest Category2\t100.00\n"+
"SPL\t09/05/2024\tTest Account3\t-40.00\n"+
"SPL\t09/05/2024\tTest Account4\t-60.00\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 10, len(allNewTransactions))
assert.Equal(t, 4, len(allNewAccounts))
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
assert.Equal(t, int64(2345), allNewTransactions[0].Amount)
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[0].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
assert.Equal(t, int64(10000), allNewTransactions[1].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
assert.Equal(t, int64(3000), allNewTransactions[2].Amount)
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[2].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
assert.Equal(t, int64(2000), allNewTransactions[3].Amount)
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
assert.Equal(t, "Test Account3", allNewTransactions[3].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
assert.Equal(t, int64(5000), allNewTransactions[4].Amount)
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
assert.Equal(t, "Test Account4", allNewTransactions[4].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime))
assert.Equal(t, int64(10000), allNewTransactions[5].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalDestinationAccountName)
assert.Equal(t, "", allNewTransactions[5].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[6].Type)
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[6].TransactionTime))
assert.Equal(t, int64(4000), allNewTransactions[6].Amount)
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[6].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[7].Type)
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[7].TransactionTime))
assert.Equal(t, int64(6000), allNewTransactions[7].Amount)
assert.Equal(t, "Test Account2", allNewTransactions[7].OriginalSourceAccountName)
assert.Equal(t, "Test Category", allNewTransactions[7].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[8].Type)
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[8].TransactionTime))
assert.Equal(t, int64(4000), allNewTransactions[8].Amount)
assert.Equal(t, "Test Account3", allNewTransactions[8].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[8].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewTransactions[9].Uid)
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[9].Type)
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[9].TransactionTime))
assert.Equal(t, int64(6000), allNewTransactions[9].Amount)
assert.Equal(t, "Test Account4", allNewTransactions[9].OriginalSourceAccountName)
assert.Equal(t, "Test Category2", allNewTransactions[9].OriginalCategoryName)
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
assert.Equal(t, "Test Account3", allNewAccounts[2].Name)
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
assert.Equal(t, "Test Account4", allNewAccounts[3].Name)
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
}
func TestIIFTransactionDataFileParseImportedData_ParseSplitTransactionDescription(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
"!SPL\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
"!ENDTRNS\t\t\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\t\"Test\"\t123.45\t\"foo bar\t#test\"\n"+
"SPL\t09/01/2024\tTest Account2\t\t-100.00\t\"foo\ttest#bar\"\n"+
"SPL\t09/01/2024\tTest Account3\t\t-23.45\t\n"+
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 2, len(allNewTransactions))
assert.Equal(t, "foo\ttest#bar", allNewTransactions[0].Comment)
assert.Equal(t, "foo bar\t#test", allNewTransactions[1].Comment)
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
"!SPL\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
"!ENDTRNS\t\t\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\tTest\t123.45\t\n"+
"SPL\t09/01/2024\tTest Account2\t\t-100.00\t\"test\"\n"+
"SPL\t09/01/2024\tTest Account3\tfoo\t-12.34\t\n"+
"SPL\t09/01/2024\tTest Account4\t\t-11.11\t\n"+
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.Nil(t, err)
assert.Equal(t, 3, len(allNewTransactions))
assert.Equal(t, "test", allNewTransactions[0].Comment)
assert.Equal(t, "foo", allNewTransactions[1].Comment)
assert.Equal(t, "Test", allNewTransactions[2].Comment)
}
func TestIIFTransactionDataFileParseImportedData_NotSupportedSplitTransaction(t *testing.T) {
converter := IifTransactionDataFileImporter
context := core.NewNullContext()
user := &models.User{
Uid: 1234567890,
DefaultCurrency: "CNY",
}
// Opening balance transaction
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!TRNS\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\t\n"+
"TRNS\tBEGINBALCHECK\t09/01/2024\tTest Account\t123.45\n"+
"SPL\tBEGINBALCHECK\t09/01/2024\tTest Account2\t-100.00\n"+
"SPL\tBEGINBALCHECK\t09/01/2024\tTest Account3\t-23.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
// Transaction with invalid amount
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\t123 45\n"+
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
// Transaction split data with invalid amount
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\t123.45\n"+
"SPL\t09/01/2024\tTest Account2\t-100 00\n"+
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
// Transaction amount not equal to sum of split data amount
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"TRNS\t09/01/2024\tTest Account\t123.00\n"+
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
@@ -515,7 +753,7 @@ func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T)
DefaultCurrency: "CNY",
}
// Missing Transaction Line
//Missing Transaction Line
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
@@ -524,6 +762,14 @@ func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T)
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Transaction And Split Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
"!ENDTRNS\t\t\t\n"+
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
// Missing Split Line
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
+196 -57
View File
@@ -3,6 +3,7 @@ package iif
import (
"fmt"
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/core"
@@ -59,6 +60,7 @@ type iifTransactionDataRowIterator struct {
dataTable *iifTransactionDataTable
currentDatasetIndex int
currentIndexInDataset int
currentSplitDataIndex int
}
// HasColumn returns whether the transaction data table has specified column
@@ -72,8 +74,15 @@ func (t *iifTransactionDataTable) TransactionRowCount() int {
totalDataRowCount := 0
for i := 0; i < len(t.transactionDatasets); i++ {
transactions := t.transactionDatasets[i]
totalDataRowCount += len(transactions.transactions)
datasets := t.transactionDatasets[i]
for j := 0; j < len(datasets.transactions); j++ {
transaction := datasets.transactions[j]
if transaction.splitData != nil {
totalDataRowCount += len(transaction.splitData)
}
}
}
return totalDataRowCount
@@ -84,7 +93,8 @@ func (t *iifTransactionDataTable) TransactionRowIterator() datatable.Transaction
return &iifTransactionDataRowIterator{
dataTable: t,
currentDatasetIndex: 0,
currentIndexInDataset: -1,
currentIndexInDataset: 0,
currentSplitDataIndex: -1,
}
}
@@ -116,6 +126,9 @@ func (t *iifTransactionDataRowIterator) HasNext() bool {
if t.currentIndexInDataset+1 < len(currentDataset.transactions) {
return true
} else if t.currentIndexInDataset < len(currentDataset.transactions) &&
t.currentSplitDataIndex+1 < len(currentDataset.transactions[t.currentIndexInDataset].splitData) {
return true
}
for i := t.currentDatasetIndex + 1; i < len(allDatasets); i++ {
@@ -134,20 +147,29 @@ func (t *iifTransactionDataRowIterator) HasNext() bool {
// Next returns the next imported data row
func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
allDatasets := t.dataTable.transactionDatasets
currentIndexInDataset := t.currentIndexInDataset
for i := t.currentDatasetIndex; i < len(allDatasets); i++ {
foundNextRow := false
dataset := allDatasets[i]
if currentIndexInDataset+1 < len(dataset.transactions) {
for j := t.currentIndexInDataset; j < len(dataset.transactions); j++ {
if t.currentSplitDataIndex+1 < len(dataset.transactions[j].splitData) {
t.currentSplitDataIndex++
foundNextRow = true
break
}
t.currentIndexInDataset++
currentIndexInDataset = t.currentIndexInDataset
t.currentSplitDataIndex = -1
}
if foundNextRow {
break
}
t.currentDatasetIndex++
t.currentIndexInDataset = -1
currentIndexInDataset = -1
t.currentIndexInDataset = 0
t.currentSplitDataIndex = -1
}
if t.currentDatasetIndex >= len(allDatasets) {
@@ -161,9 +183,28 @@ func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
}
data := currentDataset.transactions[t.currentIndexInDataset]
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data)
if len(data.splitData) < 1 {
log.Errorf(ctx, "[iif_transaction_data_table.Next] cannot parsing transaction in row#%d (dataset#%d), because split data is empty", t.currentIndexInDataset, t.currentDatasetIndex)
return nil, errs.ErrInvalidIIFFile
}
if t.currentSplitDataIndex >= len(data.splitData) {
return nil, nil
}
if len(data.splitData) > 1 {
_, err := t.isSplitTransactionSupported(ctx, currentDataset, data)
if err != nil {
return nil, err
}
}
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data, t.currentSplitDataIndex)
if err != nil {
log.Errorf(ctx, "[iif_transaction_data_table.Next] cannot parsing transaction in row#%d-split#%d (dataset#%d), because %s", t.currentIndexInDataset, t.currentSplitDataIndex, t.currentDatasetIndex, err.Error())
return nil, err
}
@@ -173,13 +214,7 @@ func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
}, nil
}
func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData) (map[datatable.TransactionDataTableColumn]string, error) {
if len(transactionData.splitData) < 1 {
return nil, errs.ErrInvalidIIFFile
} else if len(transactionData.splitData) > 1 {
return nil, errs.ErrNotSupportedSplitTransactions
}
func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData, splitDataIndex int) (map[datatable.TransactionDataTableColumn]string, error) {
var err error
data := make(map[datatable.TransactionDataTableColumn]string, len(iifTransactionSupportedColumns))
@@ -189,18 +224,18 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
return nil, err
}
transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName)
accountName1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName)
accountName2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAccountNameColumnName)
amount1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
amount2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAmountColumnName)
amountNum1, err := utils.ParseAmount(strings.ReplaceAll(amount1, ",", ""))
transactionType, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionTypeColumnName)
mainAccountName, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName)
splitAccountName, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionAccountNameColumnName)
mainAmount, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
splitAmount, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionAmountColumnName)
mainAmountNum, err := parseAmount(mainAmount)
if err != nil {
return nil, errs.ErrAmountInvalid
}
amountNum2, err := utils.ParseAmount(strings.ReplaceAll(amount2, ",", ""))
splitAmountNum, err := parseAmount(splitAmount)
if err != nil {
return nil, errs.ErrAmountInvalid
@@ -208,24 +243,35 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
if transactionType == iifTransactionTypeBeginningBalance { // balance modification
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum1)
} else if t.dataTable.incomeAccountNames[accountName1] || t.dataTable.incomeAccountNames[accountName2] { // income
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mainAccountName
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(mainAmountNum)
} else if (t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[splitAccountName]) ||
(t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[mainAccountName]) { // income
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
categoryName := ""
accountName := ""
amountNum := int64(0)
if t.dataTable.incomeAccountNames[accountName1] && !t.dataTable.incomeAccountNames[accountName2] {
categoryName = accountName1
accountName = accountName2
amountNum = amountNum2
} else if t.dataTable.incomeAccountNames[accountName2] && !t.dataTable.incomeAccountNames[accountName1] {
categoryName = accountName2
accountName = accountName1
amountNum = amountNum1
if t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[splitAccountName] {
categoryName = mainAccountName
accountName = splitAccountName
if len(transactionData.splitData) > 1 {
amountNum = splitAmountNum
} else {
amountNum = -mainAmountNum
}
} else if t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[mainAccountName] {
categoryName = splitAccountName
accountName = mainAccountName
if len(transactionData.splitData) > 1 {
amountNum = -splitAmountNum
} else {
amountNum = mainAmountNum
}
} else {
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all income account", accountName1, accountName2)
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because main account \"%s\" and split account \"%s\" are all income account", mainAccountName, splitAccountName)
return nil, errs.ErrInvalidIIFFile
}
@@ -240,22 +286,33 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
} else if t.dataTable.expenseAccountNames[accountName1] || t.dataTable.expenseAccountNames[accountName2] { // expense
} else if (t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[splitAccountName]) ||
(t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[mainAccountName]) { // expense
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
categoryName := ""
accountName := ""
amountNum := int64(0)
if t.dataTable.expenseAccountNames[accountName1] && !t.dataTable.expenseAccountNames[accountName2] {
categoryName = accountName1
accountName = accountName2
amountNum = amountNum2
} else if t.dataTable.expenseAccountNames[accountName2] && !t.dataTable.expenseAccountNames[accountName1] {
categoryName = accountName2
accountName = accountName1
amountNum = amountNum1
if t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[splitAccountName] {
categoryName = mainAccountName
accountName = splitAccountName
if len(transactionData.splitData) > 1 {
amountNum = -splitAmountNum
} else {
amountNum = mainAmountNum
}
} else if t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[mainAccountName] {
categoryName = splitAccountName
accountName = mainAccountName
if len(transactionData.splitData) > 1 {
amountNum = splitAmountNum
} else {
amountNum = -mainAmountNum
}
} else {
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all expense account", accountName1, accountName2)
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because main account \"%s\" and split account \"%s\" are all expense account", mainAccountName, splitAccountName)
return nil, errs.ErrInvalidIIFFile
}
@@ -269,26 +326,57 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
}
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum)
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
} else {
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
amountNum := int64(0)
relatedAmountNum := int64(0)
mainAccountTransferToSplitAccount := false
if amountNum1 >= 0 {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName2
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum2)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName1
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum1)
} else if amountNum2 >= 0 {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum1)
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName2
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum2)
if len(transactionData.splitData) > 1 {
amountNum = splitAmountNum
relatedAmountNum = splitAmountNum
mainAccountTransferToSplitAccount = amountNum >= 0
} else {
if mainAmountNum >= 0 {
amountNum = splitAmountNum
relatedAmountNum = mainAmountNum
mainAccountTransferToSplitAccount = false
} else if splitAmountNum >= 0 {
amountNum = mainAmountNum
relatedAmountNum = splitAmountNum
mainAccountTransferToSplitAccount = true
}
}
if mainAccountTransferToSplitAccount {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mainAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = splitAccountName
} else {
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = splitAccountName
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = mainAccountName
}
if amountNum >= 0 {
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
} else {
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum)
}
if relatedAmountNum >= 0 {
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(relatedAmountNum)
} else {
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-relatedAmountNum)
}
}
if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
if splitMemo, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionMemoColumnName); splitMemo != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = splitMemo
} else if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = memo
} else if splitName, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionNameColumnName); splitName != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = splitName
} else if name, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionNameColumnName); name != "" {
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = name
} else {
@@ -298,6 +386,49 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
return data, nil
}
func (t *iifTransactionDataRowIterator) isSplitTransactionSupported(ctx core.Context, dataset *iifTransactionDataset, transactionData *iifTransactionData) (bool, error) {
supportSplitTransactions := true
transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName)
if transactionType == iifTransactionTypeBeginningBalance { // balance modification
supportSplitTransactions = false
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parse split balance modification transaction#%d (dataset#%d)", t.currentIndexInDataset, t.currentDatasetIndex)
} else {
transactionAmountStr, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
transactionAmount, err := parseAmount(transactionAmountStr)
if err != nil {
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parsing transaction in row#%d (dataset#%d), because transaction amount \"%s\" is invalid", t.currentIndexInDataset, t.currentDatasetIndex, transactionAmountStr)
return false, errs.ErrAmountInvalid
}
splitTotalAmount := int64(0)
for i := 0; i < len(transactionData.splitData); i++ {
splitAmountStr, _ := dataset.getSplitDataItemValue(transactionData.splitData[i], iifTransactionAmountColumnName)
splitAmount, err := parseAmount(splitAmountStr)
if err != nil {
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parsing transaction in row#%d-split#%d (dataset#%d), because split amount \"%s\" is invalid", t.currentIndexInDataset, i, t.currentDatasetIndex, splitAmountStr)
return false, errs.ErrAmountInvalid
}
splitTotalAmount += splitAmount
}
if splitTotalAmount != -transactionAmount {
supportSplitTransactions = false
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parse split transaction#%d (dataset#%d), because the sum amount of each split data \"%d\" not equal to the transaction amount \"%d\"", t.currentIndexInDataset, t.currentDatasetIndex, splitTotalAmount, -transactionAmount)
}
}
if len(transactionData.splitData) > 1 && !supportSplitTransactions {
return false, errs.ErrNotSupportedSplitTransactions
}
return true, nil
}
func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransactionDataset, transactionData *iifTransactionData) (string, error) {
date, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionDateColumnName)
dateParts := strings.Split(date, "/")
@@ -316,6 +447,10 @@ func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransac
day = dateParts[2]
}
if len(year) == 2 {
year = utils.IntToString(time.Now().Year()/100) + year
}
if len(month) < 2 {
month = "0" + month
}
@@ -390,3 +525,7 @@ func getIncomeAndExpenseAccountNameMap(accountDatasets []*iifAccountDataset) (in
return incomeAccountNames, expenseAccountNames
}
func parseAmount(amount string) (int64, error) {
return utils.ParseAmount(strings.ReplaceAll(amount, ",", ""))
}
+12 -5
View File
@@ -20,8 +20,9 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
const ofxUnicodeEncoding = "unicode"
const ofxUSAsciiEncoding = "usascii"
const ofx1USAsciiEncoding = "usascii"
const ofx1UnicodeEncoding = "unicode"
const ofx1UTF8Encoding = "utf8" // non-standard ofx 1.x encoding, used by some banks (https://github.com/mayswind/ezbookkeeping/issues/48)
const ofx1SGMLDataFormat = "OFXSGML"
var ofx2HeaderPattern = regexp.MustCompile("<\\?OFX( +[A-Z]+=\"[^=]*\")* *\\?>")
@@ -231,7 +232,7 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
}
}
if fileEncoding == ofxUSAsciiEncoding {
if fileEncoding == ofx1USAsciiEncoding {
if utils.IsStringOnlyContainsDigits(fileCharset) {
fileCharset = "cp" + fileCharset
}
@@ -245,12 +246,18 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
if enc == nil {
enc = charmap.Windows1252
}
} else if fileEncoding == ofxUnicodeEncoding {
enc, _ = charset.Lookup(ofxUnicodeEncoding)
} else if fileEncoding == ofx1UnicodeEncoding {
enc, _ = charset.Lookup(ofx1UnicodeEncoding)
if enc == nil {
enc = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
}
} else if fileEncoding == ofx1UTF8Encoding {
enc, _ = charset.Lookup(ofx1UTF8Encoding)
if enc == nil {
enc = unicode.UTF8
}
} else {
log.Errorf(ctx, "[ofx_data_reader.readOFX1FileHeader] cannot parse ofx 1.x file, because encoding \"%s\" is unknown", fileEncoding)
return nil, nil, "", nil, errs.ErrInvalidOFXFile
@@ -105,6 +105,7 @@ func (t *ofxTransactionDataRowIterator) Next(ctx core.Context, user *models.User
rowItems, err := t.parseTransaction(ctx, user, data)
if err != nil {
log.Errorf(ctx, "[ofx_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
return nil, err
}
@@ -151,9 +152,10 @@ func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user
return nil, errs.ErrAmountInvalid
}
amount, err := utils.ParseAmount(strings.ReplaceAll(ofxTransaction.Amount, ",", ".")) // ofx supports decimal point or comma to indicate the start of the fractional amount
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(strings.ReplaceAll(ofxTransaction.Amount, ",", "."))) // ofx supports decimal point or comma to indicate the start of the fractional amount
if err != nil {
log.Errorf(ctx, "[ofx_transaction_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", ofxTransaction.Amount, err.Error())
return nil, errs.ErrAmountInvalid
}
@@ -105,6 +105,7 @@ func (t *qifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
rowItems, err := t.parseTransaction(ctx, user, data)
if err != nil {
log.Errorf(ctx, "[qif_transaction_data_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
return nil, err
}
@@ -3,7 +3,9 @@ package converters
import (
"github.com/mayswind/ezbookkeeping/pkg/converters/alipay"
"github.com/mayswind/ezbookkeeping/pkg/converters/base"
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
"github.com/mayswind/ezbookkeeping/pkg/converters/dsv"
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
@@ -12,6 +14,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
"github.com/mayswind/ezbookkeeping/pkg/converters/wechat"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/models"
)
// GetTransactionDataExporter returns the transaction data exporter according to the file type
@@ -61,3 +64,18 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter,
return nil, errs.ErrImportFileTypeNotSupported
}
}
// IsCustomDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
func IsCustomDelimiterSeparatedValuesFileType(fileType string) bool {
return dsv.IsDelimiterSeparatedValuesFileType(fileType)
}
// CreateNewDelimiterSeparatedValuesDataParser returns a new delimiter-separated values data parser according to the file type and encoding
func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding string) (dsv.CustomTransactionDataDsvFileParser, error) {
return dsv.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
}
// CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding
func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (base.TransactionDataImporter, error) {
return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, geoLocationSeparator, transactionTagSeparator)
}
-3
View File
@@ -12,7 +12,6 @@ const (
DECIMAL_SEPARATOR_DEFAULT DecimalSeparator = 0
DECIMAL_SEPARATOR_DOT DecimalSeparator = 1
DECIMAL_SEPARATOR_COMMA DecimalSeparator = 2
DECIMAL_SEPARATOR_SPACE DecimalSeparator = 3
DECIMAL_SEPARATOR_INVALID DecimalSeparator = 255
)
@@ -25,8 +24,6 @@ func (f DecimalSeparator) String() string {
return "Dot"
case DECIMAL_SEPARATOR_COMMA:
return "Comma"
case DECIMAL_SEPARATOR_SPACE:
return "Space"
case DECIMAL_SEPARATOR_INVALID:
return "Invalid"
default:
+4 -4
View File
@@ -15,22 +15,22 @@ type GocronLoggerAdapter struct {
// Debug logs debug log
func (logger GocronLoggerAdapter) Debug(msg string, args ...any) {
log.Debugf(core.NewNullContext(), logger.getFinalLog(msg, args...))
log.Debugf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
}
// Info logs info log
func (logger GocronLoggerAdapter) Info(msg string, args ...any) {
log.Infof(core.NewNullContext(), logger.getFinalLog(msg, args...))
log.Infof(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
}
// Warn logs warn log
func (logger GocronLoggerAdapter) Warn(msg string, args ...any) {
log.Warnf(core.NewNullContext(), logger.getFinalLog(msg, args...))
log.Warnf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
}
// Error logs error log
func (logger GocronLoggerAdapter) Error(msg string, args ...any) {
log.Errorf(core.NewNullContext(), logger.getFinalLog(msg, args...))
log.Errorf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
}
func (logger GocronLoggerAdapter) getFinalLog(msg string, args ...any) string {
+23 -1
View File
@@ -4,11 +4,13 @@ import (
"xorm.io/xorm"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// Database represents a database instance
type Database struct {
engineGroup *xorm.EngineGroup
databaseType string
engineGroup *xorm.EngineGroup
}
// NewSession starts a new session with the specified context
@@ -41,3 +43,23 @@ func (db *Database) DoTransaction(c core.Context, fn func(sess *xorm.Session) er
return nil
}
// SetSavePoint sets a save point in the current transaction for Postgres
func (db *Database) SetSavePoint(sess *xorm.Session, savePointName string) error {
if db.databaseType == settings.PostgresDbType {
_, err := sess.Exec("SAVEPOINT " + savePointName)
return err
}
return nil
}
// RollbackToSavePoint rolls back to the specified save point in the current transaction for Postgres
func (db *Database) RollbackToSavePoint(sess *xorm.Session, savePointName string) error {
if db.databaseType == settings.PostgresDbType {
_, err := sess.Exec("ROLLBACK TO SAVEPOINT " + savePointName)
return err
}
return nil
}
+2 -1
View File
@@ -104,7 +104,8 @@ func initializeDatabase(dbConfig *settings.DatabaseConfig) (*Database, error) {
engineGroup.SetConnMaxLifetime(time.Duration(dbConfig.ConnectionMaxLifeTime) * time.Second)
return &Database{
engineGroup: engineGroup,
databaseType: dbConfig.DatabaseType,
engineGroup: engineGroup,
}, nil
}
@@ -8,4 +8,6 @@ type DuplicateChecker interface {
SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string)
GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string)
RemoveCronJobRunningInfo(jobName string)
GetFailureCount(failureKey string) uint32
IncreaseFailureCount(failureKey string) uint32
}
@@ -48,3 +48,13 @@ func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, r
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
c.Current.RemoveCronJobRunningInfo(jobName)
}
// GetFailureCount returns the failure count of the specified failure key
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
return c.Current.GetFailureCount(failureKey)
}
// IncreaseFailureCount increases the failure count of the specified failure key
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
return c.Current.IncreaseFailureCount(failureKey)
}
@@ -12,4 +12,5 @@ const (
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 5
DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 6
DUPLICATE_CHECKER_TYPE_FAILURE_CHECK DuplicateCheckerType = 255
)
@@ -69,6 +69,34 @@ func (c *InMemoryDuplicateChecker) RemoveCronJobRunningInfo(jobName string) {
c.cache.Delete(c.getCacheKey(DUPLICATE_CHECKER_TYPE_BACKGROUND_CRON_JOB, 0, jobName))
}
// GetFailureCount returns the failure count of the specified failure key
func (c *InMemoryDuplicateChecker) GetFailureCount(failureKey string) uint32 {
existedFailureCount, found := c.cache.Get(c.getCacheKey(DUPLICATE_CHECKER_TYPE_FAILURE_CHECK, 0, failureKey))
if found {
return existedFailureCount.(uint32)
}
return 0
}
// IncreaseFailureCount increases the failure count of the specified failure key
func (c *InMemoryDuplicateChecker) IncreaseFailureCount(failureKey string) uint32 {
c.mutex.Lock()
defer c.mutex.Unlock()
cacheKey := c.getCacheKey(DUPLICATE_CHECKER_TYPE_FAILURE_CHECK, 0, failureKey)
_, found := c.cache.Get(cacheKey)
if found {
failureCount, _ := c.cache.IncrementUint32(cacheKey, uint32(1))
return failureCount
} else {
c.cache.Set(cacheKey, uint32(1), 1*time.Minute)
return 1
}
}
func (c *InMemoryDuplicateChecker) getCacheKey(checkerType DuplicateCheckerType, uid int64, identification string) string {
return fmt.Sprintf("%d|%d|%s", checkerType, uid, identification)
}
@@ -155,3 +155,77 @@ func TestGetOrSetRunningInfoConcurrent(t *testing.T) {
assert.Equal(t, uint32(999), setRunningInfoCount.Load())
}
func TestGetFailureCount(t *testing.T) {
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
DuplicateSubmissionsIntervalDuration: time.Second,
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
})
failureKey := "127.0.0.1"
failureCount := checker.GetFailureCount(failureKey)
assert.Equal(t, uint32(0), failureCount)
failureCount = checker.IncreaseFailureCount(failureKey)
assert.Equal(t, uint32(1), failureCount)
failureCount = checker.GetFailureCount(failureKey)
assert.Equal(t, uint32(1), failureCount)
}
func TestIncreaseFailureCount(t *testing.T) {
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
DuplicateSubmissionsIntervalDuration: time.Second,
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
})
failureKey := "127.0.0.1"
failureCount := checker.IncreaseFailureCount(failureKey)
assert.Equal(t, uint32(1), failureCount)
failureCount = checker.GetFailureCount(failureKey)
assert.Equal(t, uint32(1), failureCount)
failureCount = checker.IncreaseFailureCount(failureKey)
assert.Equal(t, uint32(2), failureCount)
failureCount = checker.GetFailureCount(failureKey)
assert.Equal(t, uint32(2), failureCount)
failureCount = checker.IncreaseFailureCount(failureKey)
assert.Equal(t, uint32(3), failureCount)
failureCount = checker.GetFailureCount(failureKey)
assert.Equal(t, uint32(3), failureCount)
}
func TestIncreaseFailureCountConcurrent(t *testing.T) {
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
DuplicateSubmissionsIntervalDuration: time.Second,
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
})
failureKey := "127.0.0.1"
concurrentCount := 10
var waitGroup sync.WaitGroup
for routineIndex := 0; routineIndex < concurrentCount; routineIndex++ {
waitGroup.Add(1)
go func(currentRoutineIndex int) {
for cycle := 0; cycle < 10; cycle++ {
checker.IncreaseFailureCount(failureKey)
}
waitGroup.Done()
}(routineIndex)
}
waitGroup.Wait()
failureCount := checker.GetFailureCount(failureKey)
assert.Equal(t, uint32(100), failureCount)
}
+1
View File
@@ -25,6 +25,7 @@ var (
ErrNoFilesUpload = NewNormalError(NormalSubcategoryGlobal, 15, http.StatusBadRequest, "no files uploaded")
ErrUploadedFileEmpty = NewNormalError(NormalSubcategoryGlobal, 16, http.StatusBadRequest, "uploaded file is empty")
ErrExceedMaxUploadFileSize = NewNormalError(NormalSubcategoryGlobal, 17, http.StatusBadRequest, "uploaded file size exceeds the maximum allowed size")
ErrFailureCountLimitReached = NewNormalError(NormalSubcategoryGlobal, 18, http.StatusBadRequest, "failure count exceeded maximum limit")
)
// GetParameterInvalidMessage returns specific error message for invalid parameter error
+6
View File
@@ -35,4 +35,10 @@ var (
ErrCannotAddTransactionBeforeBalanceModificationTransaction = NewSystemError(NormalSubcategoryTransaction, 28, http.StatusBadRequest, "cannot add transaction before balance modification transaction")
ErrBalanceModificationTransactionCannotModifyTime = NewSystemError(NormalSubcategoryTransaction, 29, http.StatusBadRequest, "balance modification transaction cannot modify transaction time")
ErrTransferTransactionAmountCannotBeLessThanZero = NewNormalError(NormalSubcategoryTransaction, 30, http.StatusBadRequest, "transfer transaction amount cannot be less than zero")
ErrImportFileEncodingIsEmpty = NewSystemError(NormalSubcategoryTransaction, 31, http.StatusBadRequest, "import file encoding is empty")
ErrImportFileEncodingNotSupported = NewSystemError(NormalSubcategoryTransaction, 32, http.StatusBadRequest, "import file encoding not supported")
ErrImportFileColumnMappingInvalid = NewSystemError(NormalSubcategoryTransaction, 33, http.StatusBadRequest, "column mapping invalid")
ErrImportFileTransactionTypeMappingInvalid = NewSystemError(NormalSubcategoryTransaction, 34, http.StatusBadRequest, "transaction type mapping invalid")
ErrImportFileTransactionTimeFormatInvalid = NewSystemError(NormalSubcategoryTransaction, 35, http.StatusBadRequest, "transaction time format invalid")
ErrImportFileTransactionTimezoneFormatInvalid = NewSystemError(NormalSubcategoryTransaction, 36, http.StatusBadRequest, "transaction time zone format invalid")
)
+7 -6
View File
@@ -4,10 +4,11 @@ import "net/http"
// Error codes related to transaction templates
var (
ErrTransactionTemplateIdInvalid = NewNormalError(NormalSubcategoryTemplate, 0, http.StatusBadRequest, "transaction template id is invalid")
ErrTransactionTemplateNotFound = NewNormalError(NormalSubcategoryTemplate, 1, http.StatusBadRequest, "transaction template not found")
ErrTransactionTemplateTypeInvalid = NewNormalError(NormalSubcategoryTemplate, 2, http.StatusBadRequest, "transaction template type is invalid")
ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
ErrTransactionTemplateIdInvalid = NewNormalError(NormalSubcategoryTemplate, 0, http.StatusBadRequest, "transaction template id is invalid")
ErrTransactionTemplateNotFound = NewNormalError(NormalSubcategoryTemplate, 1, http.StatusBadRequest, "transaction template not found")
ErrTransactionTemplateTypeInvalid = NewNormalError(NormalSubcategoryTemplate, 2, http.StatusBadRequest, "transaction template type is invalid")
ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
ErrScheduledTransactionTemplateStartDataLaterThanEndDate = NewNormalError(NormalSubcategoryTemplate, 6, http.StatusBadRequest, "scheduled transaction start date is later than end time")
)
@@ -80,6 +80,8 @@ func (e *InternationalMonetaryFundDataSource) BuildRequests() ([]*http.Request,
return nil, err
}
req.Header.Set("User-Agent", "") // Do not set custom user agent
return []*http.Request{req}, nil
}
+12
View File
@@ -10,9 +10,21 @@ var DefaultLanguage = en
// AllLanguages represents all the supported language
// To add new languages, please refer to https://ezbookkeeping.mayswind.net/translating
var AllLanguages = map[string]*LocaleInfo{
"de": {
Content: de,
},
"en": {
Content: en,
},
"es": {
Content: es,
},
"ja": {
Content: ja,
},
"ru": {
Content: ru,
},
"vi": {
Content: vi,
},
+30
View File
@@ -0,0 +1,30 @@
package locales
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
)
var de = &LocaleTextItems{
DefaultTypes: &DefaultTypes{
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
},
DataConverterTextItems: &DataConverterTextItems{
Alipay: "Alipay",
WeChatWallet: "Wallet",
},
VerifyEmailTextItems: &VerifyEmailTextItems{
Title: "E-Mail verifizieren",
SalutationFormat: "Hallo %s,",
DescriptionAboveBtn: "Bitte klicken Sie auf den untenstehenden Link, um Ihre E-Mail-Adresse zu bestätigen.",
VerifyEmail: "E-Mail verifizieren",
DescriptionBelowBtnFormat: "Wenn Sie kein %s Konto erstellt haben, ignorieren Sie bitte diese E-Mail. Wenn Sie den obigen Link nicht anklicken können, kopieren Sie bitte die obige URL und fügen Sie sie in Ihren Browser ein. Der Verifizierungslink wird nach %v Minuten ablaufen.",
},
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "Passwort zurücksetzen",
SalutationFormat: "Hallo %s,",
DescriptionAboveBtn: "Wir haben kürzlich eine Anfrage zum Zurücksetzen Ihres Passworts erhalten. Sie können auf den untenstehenden Link klicken, um Ihr Passwort zurückzusetzen.",
ResetPassword: "Passwort zurücksetzen",
DescriptionBelowBtnFormat: "Wenn Sie nicht angefordert haben, Ihr Passwort zurückzusetzen, ignorieren Sie bitte diese E-Mail. Wenn Sie den obigen Link nicht anklicken können, kopieren Sie bitte die obige URL und fügen Sie sie in Ihren Browser ein. Der Link zum Zurücksetzen des Passworts wird nach %v Minuten ablaufen.",
},
}
+30
View File
@@ -0,0 +1,30 @@
package locales
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
)
var es = &LocaleTextItems{
DefaultTypes: &DefaultTypes{
DecimalSeparator: core.DECIMAL_SEPARATOR_DOT,
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_COMMA,
},
DataConverterTextItems: &DataConverterTextItems{
Alipay: "Alipay",
WeChatWallet: "Wallet",
},
VerifyEmailTextItems: &VerifyEmailTextItems{
Title: "Verifique su cuenta de correo",
SalutationFormat: "Hola %s,",
DescriptionAboveBtn: "Por favor, haga click en el siguiente enlace para confirmar su cuenta de correo.",
VerifyEmail: "Verificar correo",
DescriptionBelowBtnFormat: "Si no registró una cuenta de %s, simplemente haga caso omiso a este correo. Si no puede hacer click en el link de verificación, copie la url arriba mostrada y péguela en su navegador. El enlace de verificación de correo expira pasados %v minutos.",
},
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "Restablezca su Contraseña",
SalutationFormat: "Hola %s,",
DescriptionAboveBtn: "Hemos recibido una solicitud para restablecer su contraseña. Puede hacer click en el siguiente link para restablecer su contraseña.",
ResetPassword: "Restablecer Contraseña",
DescriptionBelowBtnFormat: "Si no solicitó un restablecimiento de contraseña, simplemente descarte este correo. Si no puede hacer click en el link anterior, copie la url arriba mostrada y péguela en su navegadror. El enlace de restablecimiento de contraseña expira pasados %v minutos.",
},
}
+30
View File
@@ -0,0 +1,30 @@
package locales
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
)
var ja = &LocaleTextItems{
DefaultTypes: &DefaultTypes{
DecimalSeparator: core.DECIMAL_SEPARATOR_DOT,
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_COMMA,
},
DataConverterTextItems: &DataConverterTextItems{
Alipay: "Alipay",
WeChatWallet: "Wallet",
},
VerifyEmailTextItems: &VerifyEmailTextItems{
Title: "メールの確認",
SalutationFormat: "こんにちは%s,",
DescriptionAboveBtn: "次のリンクをクリックしてメールアドレスを確認してください。",
VerifyEmail: "メールを確認",
DescriptionBelowBtnFormat: "%sアカウントに登録していない場合はこのメールを無視してください。上記のリンクをクリックできない場合は上記のURLをコピーしてブラウザに貼り付けてください。メールの確認リンクは%v分後に期限切れになります。",
},
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "パスワードのリセット",
SalutationFormat: "こんにちは%s,",
DescriptionAboveBtn: "パスワードリセットのリクエストを受け取りました。次のリンクをクリックしてパスワードをリセットしてください。",
ResetPassword: "パスワードをリセット",
DescriptionBelowBtnFormat: "パスワードのリセットをリクエストしていない場合はこのメールを無視してください。上記のリンクをクリックできない場合は、上記のURLをコピーしてブラウザに貼り付けてください。パスワードリセットのリンクは%v分後に期限切れになります。",
},
}
+30
View File
@@ -0,0 +1,30 @@
package locales
import (
"github.com/mayswind/ezbookkeeping/pkg/core"
)
var ru = &LocaleTextItems{
DefaultTypes: &DefaultTypes{
DecimalSeparator: core.DECIMAL_SEPARATOR_DOT,
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_COMMA,
},
DataConverterTextItems: &DataConverterTextItems{
Alipay: "Alipay",
WeChatWallet: "Wallet",
},
VerifyEmailTextItems: &VerifyEmailTextItems{
Title: "Подтвердите электронную почту",
SalutationFormat: "Здравствуйте %s,",
DescriptionAboveBtn: "Нажмите на ссылку ниже, чтобы подтвердить свой адрес электронной почты.",
VerifyEmail: "Подтвердить электронную почту",
DescriptionBelowBtnFormat: "Если вы не регистрировали учетную запись %s, просто проигнорируйте это письмо. Если вы не можете нажать на ссылку выше, скопируйте указанный выше URL и вставьте его в свой браузер. Срок действия ссылки для проверки электронной почты истечет через %v минут.",
},
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "Сброс пароля",
SalutationFormat: "Здравствуйте %s,",
DescriptionAboveBtn: "Недавно мы получили запрос на сброс вашего пароля. Нажмите на ссылку ниже, чтобы сбросить свой пароль.",
ResetPassword: "Сбросить пароль",
DescriptionBelowBtnFormat: "Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. Если вы не можете нажать на ссылку выше, скопируйте указанный выше URL и вставьте его в браузер. Ссылка для сброса пароля истечет через %v минут.",
},
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
var vi = &LocaleTextItems{
DefaultTypes: &DefaultTypes{
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
},
DataConverterTextItems: &DataConverterTextItems{
+1 -1
View File
@@ -141,7 +141,7 @@ func SetLoggerConfiguration(config *settings.Config, isDisableBootLog bool) erro
return nil
}
// DebugfWithRequestId logs debug log with custom format
// Debugf logs debug log with custom format
func Debugf(c core.Context, format string, args ...any) {
if c == nil {
defaultLogger.Debug(getFinalLog(format, args...))
+3 -3
View File
@@ -64,7 +64,7 @@ type Account struct {
Category AccountCategory `xorm:"NOT NULL"`
Type AccountType `xorm:"NOT NULL"`
ParentAccountId int64 `xorm:"INDEX(IDX_account_uid_deleted_parent_account_id_order) NOT NULL"`
Name string `xorm:"VARCHAR(32) NOT NULL"`
Name string `xorm:"VARCHAR(64) NOT NULL"`
DisplayOrder int32 `xorm:"INDEX(IDX_account_uid_deleted_parent_account_id_order) NOT NULL"`
Icon int64 `xorm:"NOT NULL"`
Color string `xorm:"VARCHAR(6) NOT NULL"`
@@ -85,7 +85,7 @@ type AccountExtend struct {
// AccountCreateRequest represents all parameters of account creation request
type AccountCreateRequest struct {
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Category AccountCategory `json:"category" binding:"required"`
Type AccountType `json:"type" binding:"required"`
Icon int64 `json:"icon,string" binding:"required,min=1"`
@@ -102,7 +102,7 @@ type AccountCreateRequest struct {
// AccountModifyRequest represents all parameters of account modification request
type AccountModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Category AccountCategory `json:"category" binding:"required"`
Icon int64 `json:"icon,string" binding:"min=1"`
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
+7 -7
View File
@@ -311,8 +311,8 @@ type TransactionStatisticResponseItem struct {
TotalAmount int64 `json:"amount"`
}
// TransactionStatisticTrendsItem represents the data within each statistic interval
type TransactionStatisticTrendsItem struct {
// TransactionStatisticTrendsResponseItem represents the data within each statistic interval
type TransactionStatisticTrendsResponseItem struct {
Year int32 `json:"year"`
Month int32 `json:"month"`
Items []*TransactionStatisticResponseItem `json:"items"`
@@ -493,21 +493,21 @@ func (s TransactionInfoResponseSlice) Less(i, j int) bool {
return s[i].Id > s[j].Id
}
// TransactionStatisticTrendsItemSlice represents the slice data structure of TransactionStatisticTrendsItem
type TransactionStatisticTrendsItemSlice []*TransactionStatisticTrendsItem
// TransactionStatisticTrendsResponseItemSlice represents the slice data structure of TransactionStatisticTrendsResponseItem
type TransactionStatisticTrendsResponseItemSlice []*TransactionStatisticTrendsResponseItem
// Len returns the count of items
func (s TransactionStatisticTrendsItemSlice) Len() int {
func (s TransactionStatisticTrendsResponseItemSlice) Len() int {
return len(s)
}
// Swap swaps two items
func (s TransactionStatisticTrendsItemSlice) Swap(i, j int) {
func (s TransactionStatisticTrendsResponseItemSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Less reports whether the first item is less than the second one
func (s TransactionStatisticTrendsItemSlice) Less(i, j int) bool {
func (s TransactionStatisticTrendsResponseItemSlice) Less(i, j int) bool {
if s[i].Year != s[j].Year {
return s[i].Year < s[j].Year
}
+4 -4
View File
@@ -20,7 +20,7 @@ type TransactionCategory struct {
Deleted bool `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
Type TransactionCategoryType `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
ParentCategoryId int64 `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
Name string `xorm:"VARCHAR(32) NOT NULL"`
Name string `xorm:"VARCHAR(64) NOT NULL"`
DisplayOrder int32 `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
Icon int64 `xorm:"NOT NULL"`
Color string `xorm:"VARCHAR(6) NOT NULL"`
@@ -44,7 +44,7 @@ type TransactionCategoryGetRequest struct {
// TransactionCategoryCreateRequest represents all parameters of single transaction category creation request
type TransactionCategoryCreateRequest struct {
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Type TransactionCategoryType `json:"type" binding:"required"`
ParentId int64 `json:"parentId,string" binding:"min=0"`
Icon int64 `json:"icon,string" binding:"min=1"`
@@ -60,7 +60,7 @@ type TransactionCategoryCreateBatchRequest struct {
// TransactionCategoryCreateWithSubCategories represents all parameters of multi transaction categories creation request
type TransactionCategoryCreateWithSubCategories struct {
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Type TransactionCategoryType `json:"type" binding:"required"`
Icon int64 `json:"icon,string" binding:"min=1"`
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
@@ -71,7 +71,7 @@ type TransactionCategoryCreateWithSubCategories struct {
// TransactionCategoryModifyRequest represents all parameters of transaction category modification request
type TransactionCategoryModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
ParentId int64 `json:"parentId,string" binding:"min=0"`
Icon int64 `json:"icon,string" binding:"min=1"`
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
+3 -3
View File
@@ -5,7 +5,7 @@ type TransactionTag struct {
TagId int64 `xorm:"PK"`
Uid int64 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
Deleted bool `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
Name string `xorm:"VARCHAR(32) NOT NULL"`
Name string `xorm:"VARCHAR(64) NOT NULL"`
DisplayOrder int32 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
Hidden bool `xorm:"NOT NULL"`
CreatedUnixTime int64
@@ -20,13 +20,13 @@ type TransactionTagGetRequest struct {
// TransactionTagCreateRequest represents all parameters of transaction tag creation request
type TransactionTagCreateRequest struct {
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTagModifyRequest represents all parameters of transaction tag modification request
type TransactionTagModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTagHideRequest represents all parameters of transaction tag hiding request
+29 -8
View File
@@ -2,6 +2,7 @@ package models
import (
"strings"
"time"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
@@ -29,15 +30,17 @@ const (
type TransactionTemplate struct {
TemplateId int64 `xorm:"PK"`
Uid int64 `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) NOT NULL"`
Deleted bool `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at) NOT NULL"`
TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at) NOT NULL"`
Name string `xorm:"VARCHAR(32) NOT NULL"`
Deleted bool `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time) NOT NULL"`
TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time) NOT NULL"`
Name string `xorm:"VARCHAR(64) NOT NULL"`
Type TransactionType `xorm:"NOT NULL"`
CategoryId int64 `xorm:"NOT NULL"`
AccountId int64 `xorm:"NOT NULL"`
ScheduledFrequencyType TransactionScheduleFrequencyType `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
ScheduledFrequencyType TransactionScheduleFrequencyType `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledFrequency string `xorm:"VARCHAR(100)"`
ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
ScheduledStartTime *int64 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledEndTime *int64 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
ScheduledTimezoneUtcOffset int16
TagIds string `xorm:"VARCHAR(255) NOT NULL"`
Amount int64 `xorm:"NOT NULL"`
@@ -65,7 +68,7 @@ type TransactionTemplateGetRequest struct {
// TransactionTemplateCreateRequest represents all parameters of transaction template creation request
type TransactionTemplateCreateRequest struct {
TemplateType TransactionTemplateType `json:"templateType"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Type TransactionType `json:"type" binding:"required"`
CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
@@ -77,6 +80,8 @@ type TransactionTemplateCreateRequest struct {
Comment string `json:"comment" binding:"max=255"`
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
ClientSessionId string `json:"clientSessionId"`
}
@@ -84,13 +89,13 @@ type TransactionTemplateCreateRequest struct {
// TransactionTemplateModifyNameRequest represents all parameters of transaction template name modification request
type TransactionTemplateModifyNameRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
}
// TransactionTemplateModifyRequest represents all parameters of transaction template modification request
type TransactionTemplateModifyRequest struct {
Id int64 `json:"id,string" binding:"required,min=1"`
Name string `json:"name" binding:"required,notBlank,max=32"`
Name string `json:"name" binding:"required,notBlank,max=64"`
Type TransactionType `json:"type" binding:"required"`
CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
@@ -102,6 +107,8 @@ type TransactionTemplateModifyRequest struct {
Comment string `json:"comment" binding:"max=255"`
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
}
@@ -133,6 +140,8 @@ type TransactionTemplateInfoResponse struct {
Name string `json:"name"`
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType,omitempty"`
ScheduledFrequency *string `json:"scheduledFrequency,omitempty"`
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
ScheduledAt *int16 `json:"scheduledAt,omitempty"`
DisplayOrder int32 `json:"displayOrder"`
Hidden bool `json:"hidden"`
@@ -171,6 +180,18 @@ func (t *TransactionTemplate) ToTransactionTemplateInfoResponse(serverUtcOffset
response.ScheduledFrequencyType = &t.ScheduledFrequencyType
response.ScheduledFrequency = &t.ScheduledFrequency
response.ScheduledAt = &t.ScheduledAt
templateTimeZone := time.FixedZone("Template Timezone", int(t.ScheduledTimezoneUtcOffset)*60)
if t.ScheduledStartTime != nil {
startDate := utils.FormatUnixTimeToLongDate(*t.ScheduledStartTime, templateTimeZone)
response.ScheduledStartDate = &startDate
}
if t.ScheduledEndTime != nil {
endDate := utils.FormatUnixTimeToLongDate(*t.ScheduledEndTime, templateTimeZone)
response.ScheduledEndDate = &endDate
}
}
return response
+7 -7
View File
@@ -127,25 +127,25 @@ func TestTransactionInfoResponseSliceLess(t *testing.T) {
assert.Equal(t, int64(4), transactionRespSlice[4].Id)
}
func TestTransactionStatisticTrendsItemSliceLess(t *testing.T) {
var transactionTrendsSlice TransactionStatisticTrendsItemSlice
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
func TestTransactionStatisticTrendsResponseItemSliceLess(t *testing.T) {
var transactionTrendsSlice TransactionStatisticTrendsResponseItemSlice
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
Year: 2024,
Month: 9,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
Year: 2022,
Month: 10,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
Year: 2023,
Month: 1,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
Year: 2022,
Month: 2,
})
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
Year: 2024,
Month: 1,
})
+20 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
@@ -270,7 +271,9 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
}
}
return s.UserDataDB(mainAccount.Uid).DoTransaction(c, func(sess *xorm.Session) error {
userDataDb := s.UserDataDB(mainAccount.Uid)
return userDataDb.DoTransaction(c, func(sess *xorm.Session) error {
for i := 0; i < len(allAccounts); i++ {
account := allAccounts[i]
_, err := sess.Insert(account)
@@ -282,9 +285,25 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
for i := 0; i < len(allInitTransactions); i++ {
transaction := allInitTransactions[i]
insertTransactionSavePointName := "insert_transaction"
err := userDataDb.SetSavePoint(sess, insertTransactionSavePointName)
if err != nil {
log.Errorf(c, "[accounts.CreateAccounts] failed to set save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
return err
}
createdRows, err := sess.Insert(transaction)
if err != nil || createdRows < 1 { // maybe another transaction has same time
err = userDataDb.RollbackToSavePoint(sess, insertTransactionSavePointName)
if err != nil {
log.Errorf(c, "[accounts.CreateAccounts] failed to rollback to save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
return err
}
sameSecondLatestTransaction := &models.Transaction{}
minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
+1 -1
View File
@@ -136,7 +136,7 @@ func (s *TransactionTemplateService) ModifyTemplate(c core.Context, template *mo
template.UpdatedUnixTime = time.Now().Unix()
return s.UserDataDB(template.Uid).DoTransaction(c, func(sess *xorm.Session) error {
updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "scheduled_frequency_type", "scheduled_frequency", "scheduled_at", "scheduled_timezone_utc_offset", "tag_ids", "amount", "related_account_id", "related_account_amount", "hide_amount", "comment", "updated_unix_time").Where("uid=? AND deleted=?", template.Uid, false).Update(template)
updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "scheduled_frequency_type", "scheduled_frequency", "scheduled_start_time", "scheduled_end_time", "scheduled_at", "scheduled_timezone_utc_offset", "tag_ids", "amount", "related_account_id", "related_account_amount", "hide_amount", "comment", "updated_unix_time").Where("uid=? AND deleted=?", template.Uid, false).Update(template)
if err != nil {
return err
+79 -9
View File
@@ -252,8 +252,10 @@ func (s *TransactionService) CreateTransaction(c core.Context, transaction *mode
UpdatedUnixTime: now,
}
return s.UserDataDB(transaction.Uid).DoTransaction(c, func(sess *xorm.Session) error {
return s.doCreateTransaction(sess, transaction, transactionTagIndexes, tagIds, pictureIds, pictureUpdateModel)
userDataDb := s.UserDataDB(transaction.Uid)
return userDataDb.DoTransaction(c, func(sess *xorm.Session) error {
return s.doCreateTransaction(c, userDataDb, sess, transaction, transactionTagIndexes, tagIds, pictureIds, pictureUpdateModel)
})
}
@@ -355,12 +357,14 @@ func (s *TransactionService) BatchCreateTransactions(c core.Context, uid int64,
allTransactionTagIds[transaction.TransactionId] = uniqueTagIds
}
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
userDataDb := s.UserDataDB(uid)
return userDataDb.DoTransaction(c, func(sess *xorm.Session) error {
for i := 0; i < len(transactions); i++ {
transaction := transactions[i]
transactionTagIndexes := allTransactionTagIndexes[transaction.TransactionId]
transactionTagIds := allTransactionTagIds[transaction.TransactionId]
err := s.doCreateTransaction(sess, transaction, transactionTagIndexes, transactionTagIds, nil, nil)
err := s.doCreateTransaction(c, userDataDb, sess, transaction, transactionTagIndexes, transactionTagIds, nil, nil)
if err != nil {
transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)
@@ -394,7 +398,7 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
for i := 0; i < s.UserDataDBCount(); i++ {
var templates []*models.TransactionTemplate
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, minScheduledAt, maxScheduledAt).Find(&templates)
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, startTime.Unix(), startTime.Unix(), minScheduledAt, maxScheduledAt).Find(&templates)
if err != nil {
return err
@@ -453,6 +457,18 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
continue
}
if template.ScheduledStartTime != nil && *template.ScheduledStartTime > transactionUnixTime {
skipCount++
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is earlier than the start time %d", template.TemplateId, *template.ScheduledStartTime)
continue
}
if template.ScheduledEndTime != nil && *template.ScheduledEndTime < transactionUnixTime {
skipCount++
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is later than the end time %d", template.TemplateId, *template.ScheduledEndTime)
continue
}
var transactionDbType models.TransactionDbType
if template.Type == models.TRANSACTION_TYPE_EXPENSE {
@@ -546,6 +562,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
has, err := sess.ID(transaction.TransactionId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(oldTransaction)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to get current transaction, because %s", err.Error())
return err
} else if !has {
return errs.ErrTransactionNotFound
@@ -568,6 +585,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
sourceAccount, destinationAccount, err := s.getAccountModels(sess, transaction)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to get account, because %s", err.Error())
return err
}
@@ -592,6 +610,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
oldSourceAccount, oldDestinationAccount, err := s.getOldAccountModels(sess, transaction, oldTransaction, sourceAccount, destinationAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to get old account, because %s", err.Error())
return err
}
@@ -625,6 +644,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
has, err = sess.Where("uid=? AND deleted=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, false, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to get trasaction time, because %s", err.Error())
return err
}
@@ -706,6 +726,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
}
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to get whether other transactions exist, because %s", err.Error())
return err
} else if otherTransactionExists {
return errs.ErrCannotAddTransactionBeforeBalanceModificationTransaction
@@ -716,6 +737,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(transaction.TransactionId).Cols(updateCols...).Where("uid=? AND deleted=?", transaction.Uid, false).Update(transaction)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update transaction, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrTransactionNotFound
@@ -732,6 +754,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(relatedTransaction.TransactionId).Cols(relatedUpdateCols...).Where("uid=? AND deleted=?", relatedTransaction.Uid, false).Update(relatedTransaction)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update related transaction, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -748,6 +771,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).In("tag_id", removeTagIds).Update(tagIndexUpdateModel)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to remove old transaction tag index, because %s", err.Error())
return err
} else if deletedRows < 1 {
return errs.ErrTransactionTagNotFound
@@ -762,6 +786,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
_, err := sess.Insert(transactionTagIndex)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to add new transaction tag index, because %s", err.Error())
return err
}
}
@@ -773,6 +798,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
_, err := sess.Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).Update(tagIndexUpdateModel)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update transaction tag index, because %s", err.Error())
return err
}
}
@@ -787,6 +813,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).In("picture_id", removePictureIds).Update(pictureUpdateModel)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to remove old transaction picture info, because %s", err.Error())
return err
} else if deletedRows < 1 {
return errs.ErrTransactionPictureNotFound
@@ -802,6 +829,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
_, err = sess.Cols("transaction_id", "updated_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, models.TransactionPictureNewPictureTransactionId).In("picture_id", addPictureIds).Update(pictureUpdateModel)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update new transaction picture info, because %s", err.Error())
return err
}
}
@@ -817,6 +845,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.RelatedAccountAmount, transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -837,6 +866,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.Amount, oldAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -848,6 +878,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", newAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -868,6 +899,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)-(%d)", oldTransaction.Amount, oldAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -879,6 +911,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", newAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -899,6 +932,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)-(%d)", oldTransaction.Amount, oldSourceAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -910,6 +944,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", newSourceAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -930,6 +965,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(oldDestinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.RelatedAccountAmount, oldDestinationAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldDestinationAccount.Uid, false).Update(oldDestinationAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -941,6 +977,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
updatedRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", newDestinationAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount)
if err != nil {
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1541,7 +1578,7 @@ func (s *TransactionService) GetTransactionIds(transactions []*models.Transactio
return transactionIds
}
func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64, pictureIds []int64, pictureUpdateModel *models.TransactionPictureInfo) error {
func (s *TransactionService) doCreateTransaction(c core.Context, database *datastore.Database, sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64, pictureIds []int64, pictureUpdateModel *models.TransactionPictureInfo) error {
// Get and verify source and destination account
sourceAccount, destinationAccount, err := s.getAccountModels(sess, transaction)
@@ -1593,6 +1630,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
otherTransactionExists, err := sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND account_id=?", transaction.Uid, false, sourceAccount.AccountId).Limit(1).Exist(&models.Transaction{})
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to get whether other transactions exist, because %s", err.Error())
return err
} else if otherTransactionExists {
return errs.ErrBalanceModificationTransactionCannotAddWhenNotEmpty
@@ -1610,6 +1648,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
}
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to get whether other transactions exist, because %s", err.Error())
return err
} else if otherTransactionExists {
return errs.ErrCannotAddTransactionBeforeBalanceModificationTransaction
@@ -1623,9 +1662,30 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
relatedTransaction = s.GetRelatedTransferTransaction(transaction)
}
insertTransactionSavePointName := "insert_transaction"
err = database.SetSavePoint(sess, insertTransactionSavePointName)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to set save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
return err
}
createdRows, err := sess.Insert(transaction)
if err != nil || createdRows < 1 { // maybe another transaction has same time
if err != nil {
log.Warnf(c, "[transactions.doCreateTransaction] cannot create trasaction, because %s, regenerate transaction time value", err.Error())
} else {
log.Warnf(c, "[transactions.doCreateTransaction] cannot create trasaction, regenerate transaction time value")
}
err = database.RollbackToSavePoint(sess, insertTransactionSavePointName)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to rollback to save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
return err
}
sameSecondLatestTransaction := &models.Transaction{}
minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
@@ -1633,6 +1693,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
has, err := sess.Where("uid=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to get trasaction time, because %s", err.Error())
return err
} else if !has {
return errs.ErrDatabaseOperationFailed
@@ -1644,6 +1705,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
createdRows, err := sess.Insert(transaction)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction again, because %s", err.Error())
return err
} else if createdRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1660,6 +1722,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
createdRows, err := sess.Insert(relatedTransaction)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to add related transaction, because %s", err.Error())
return err
} else if createdRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1677,6 +1740,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
_, err := sess.Insert(transactionTagIndex)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction tag index, because %s", err.Error())
return err
}
}
@@ -1687,6 +1751,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
_, err = sess.Cols("transaction_id", "updated_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, models.TransactionPictureNewPictureTransactionId).In("picture_id", pictureIds).Update(pictureUpdateModel)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to update transaction picture info, because %s", err.Error())
return err
}
}
@@ -1697,6 +1762,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1706,6 +1772,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1715,6 +1782,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1724,6 +1792,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
updatedSourceRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedSourceRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1733,6 +1802,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
updatedDestinationRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount)
if err != nil {
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
return err
} else if updatedDestinationRows < 1 {
return errs.ErrDatabaseOperationFailed
@@ -1913,7 +1983,7 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
if noTags {
subQuery := builder.Select("transaction_id").From("transaction_tag_index").Where(subQueryCondition)
sess.NotIn("transaction_id", subQuery)
sess.NotIn("transaction_id", subQuery).NotIn("related_id", subQuery)
return sess
}
@@ -1929,9 +1999,9 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
}
if tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ANY || tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ALL {
sess.In("transaction_id", subQuery)
sess.And(builder.Or(builder.In("transaction_id", subQuery), builder.In("related_id", subQuery)))
} else if tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ANY || tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ALL {
sess.NotIn("transaction_id", subQuery)
sess.NotIn("transaction_id", subQuery).NotIn("related_id", subQuery)
}
return sess
+9 -5
View File
@@ -58,7 +58,7 @@ var (
)
// GetUserByUsernameOrEmailAndPassword returns the user model according to login name and password
func (s *UserService) GetUserByUsernameOrEmailAndPassword(c core.Context, loginname string, password string) (*models.User, error) {
func (s *UserService) GetUserByUsernameOrEmailAndPassword(c core.Context, loginname string, password string) (*models.User, int64, error) {
var user *models.User
var err error
@@ -71,14 +71,18 @@ func (s *UserService) GetUserByUsernameOrEmailAndPassword(c core.Context, loginn
}
if err != nil {
return nil, err
return nil, 0, err
}
if user == nil {
return nil, 0, errs.ErrUserNotFound
}
if !s.IsPasswordEqualsUserPassword(password, user) {
return nil, errs.ErrUserPasswordWrong
return nil, user.Uid, errs.ErrUserPasswordWrong
}
return user, nil
return user, user.Uid, nil
}
// GetUserById returns the user model according to user uid
@@ -301,7 +305,7 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
updateCols = append(updateCols, "short_time_format")
}
if core.DECIMAL_SEPARATOR_DEFAULT <= user.DecimalSeparator && user.DecimalSeparator <= core.DECIMAL_SEPARATOR_SPACE {
if core.DECIMAL_SEPARATOR_DEFAULT <= user.DecimalSeparator && user.DecimalSeparator <= core.DECIMAL_SEPARATOR_COMMA {
updateCols = append(updateCols, "decimal_separator")
}
+7
View File
@@ -144,6 +144,8 @@ const (
defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes
defaultEmailVerifyTokenExpiredTime uint32 = 3600 // 60 minutes
defaultPasswordResetTokenExpiredTime uint32 = 3600 // 60 minutes
defaultMaxFailuresPerIpPerMinute uint32 = 5
defaultMaxFailuresPerUserPerMinute uint32 = 5
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
@@ -286,6 +288,8 @@ type Config struct {
EmailVerifyTokenExpiredTimeDuration time.Duration
PasswordResetTokenExpiredTime uint32
PasswordResetTokenExpiredTimeDuration time.Duration
MaxFailuresPerIpPerMinute uint32
MaxFailuresPerUserPerMinute uint32
EnableRequestIdHeader bool
// User
@@ -768,6 +772,9 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
config.MaxFailuresPerIpPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_ip_per_minute", defaultMaxFailuresPerIpPerMinute)
config.MaxFailuresPerUserPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_user_per_minute", defaultMaxFailuresPerUserPerMinute)
config.EnableRequestIdHeader = getConfigItemBoolValue(configFile, sectionName, "request_id_header", true)
return nil
+30
View File
@@ -9,6 +9,7 @@ import (
)
const (
longDateFormat = "2006-01-02"
longDateTimeFormat = "2006-01-02 15:04:05"
longDateTimeWithTimezoneFormat = "2006-01-02 15:04:05Z07:00"
longDateTimeWithTimezoneFormat2 = "2006-01-02 15:04:05 Z0700"
@@ -42,6 +43,17 @@ func ParseNumericYearMonth(yearMonth string) (int32, int32, error) {
return year, month, nil
}
// FormatUnixTimeToLongDate returns a textual representation of the unix time formatted by long date time format
func FormatUnixTimeToLongDate(unixTime int64, timezone *time.Location) string {
t := parseFromUnixTime(unixTime)
if timezone != nil {
t = t.In(timezone)
}
return t.Format(longDateFormat)
}
// FormatUnixTimeToLongDateTime returns a textual representation of the unix time formatted by long date time format
func FormatUnixTimeToLongDateTime(unixTime int64, timezone *time.Location) string {
t := parseFromUnixTime(unixTime)
@@ -119,6 +131,24 @@ func GetMaxUnixTimeWithSameLocalDateTime(unixTime int64, currentUtcOffset int16)
return unixTime + int64(currentUtcOffset)*60 - westernmostTimezoneUtcOffset*60
}
// ParseFromLongDateFirstTime parses a formatted string in long date format
func ParseFromLongDateFirstTime(t string, utcOffset int16) (time.Time, error) {
timezone := time.FixedZone("Timezone", int(utcOffset)*60)
return time.ParseInLocation(longDateFormat, t, timezone)
}
// ParseFromLongDateLastTime parses a formatted string in long date format
func ParseFromLongDateLastTime(t string, utcOffset int16) (time.Time, error) {
timezone := time.FixedZone("Timezone", int(utcOffset)*60)
lastTime, err := time.ParseInLocation(longDateFormat, t, timezone)
if err != nil {
return lastTime, err
}
return lastTime.Add(24 * time.Hour).Add(-1 * time.Nanosecond), nil
}
// ParseFromLongDateTimeToMinUnixTime parses a formatted string in long date time format to minimal unix time (the westernmost timezone)
func ParseFromLongDateTimeToMinUnixTime(t string) (time.Time, error) {
timezone := time.FixedZone("Timezone", easternmostTimezoneUtcOffset*60)
+32
View File
@@ -18,6 +18,20 @@ func TestParseNumericYearMonth(t *testing.T) {
assert.Equal(t, expectedMonth, actualMonth)
}
func TestFormatUnixTimeToLongDate(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8
expectedValue := "2021-03-31"
actualValue := FormatUnixTimeToLongDate(unixTime, utcTimezone)
assert.Equal(t, expectedValue, actualValue)
expectedValue = "2021-04-01"
actualValue = FormatUnixTimeToLongDate(unixTime, utc8Timezone)
assert.Equal(t, expectedValue, actualValue)
}
func TestFormatUnixTimeToLongDateTime(t *testing.T) {
unixTime := int64(1617228083)
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
@@ -106,6 +120,24 @@ func TestGetMaxUnixTimeWithSameLocalDateTime(t *testing.T) {
assert.Equal(t, expectedValue, actualValue)
}
func TestParseFromLongDateFirstTime(t *testing.T) {
expectedValue := int64(1690819200)
actualTime, err := ParseFromLongDateFirstTime("2023-08-01", 480)
assert.Equal(t, nil, err)
actualValue := actualTime.Unix()
assert.Equal(t, expectedValue, actualValue)
}
func TestParseFromLongDateLastTime(t *testing.T) {
expectedValue := int64(1690905599)
actualTime, err := ParseFromLongDateLastTime("2023-08-01", 480)
assert.Equal(t, nil, err)
actualValue := actualTime.Unix()
assert.Equal(t, expectedValue, actualValue)
}
func TestParseFromLongDateTimeToMinUnixTime(t *testing.T) {
expectedValue := int64(1690797600)
actualTime, err := ParseFromLongDateTimeToMinUnixTime("2023-08-01 00:00:00")
+92 -94
View File
@@ -4,10 +4,10 @@
</v-app>
<v-snackbar class="cursor-pointer" color="notification-background" location="top"
:multi-line="true" :timeout="-1" :close-on-content-click="true" v-model="showNotification">
<v-tooltip activator="parent">{{ $t('Click to close') }}</v-tooltip>
<v-tooltip activator="parent">{{ tt('Click to close') }}</v-tooltip>
<div class="d-inline-flex">
<img alt="logo" class="notification-logo" :src="ezBookkeepingLogoPath" />
<span class="ml-2">{{ $t('global.app.title') }}</span>
<img alt="logo" class="notification-logo" :src="APPLICATION_LOGO_PATH" />
<span class="ml-2">{{ tt('global.app.title') }}</span>
</div>
<div>
{{ currentNotificationContent }}
@@ -15,106 +15,104 @@
</v-snackbar>
</template>
<script>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useTheme } from 'vuetify';
import { register } from 'register-service-worker';
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useTokensStore } from '@/stores/token.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import { useI18n } from '@/locales/helpers.ts';
import assetConstants from '@/consts/asset.js';
import { isProduction } from '@/lib/version.js';
import { loadMapAssets } from '@/lib/map/index.js';
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
import { useRootStore } from '@/stores/index.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useUserStore } from '@/stores/user.ts';
import { useTokensStore } from '@/stores/token.ts';
import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
export default {
data() {
return {
showNotification: false
}
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUserStore, useTokensStore, useExchangeRatesStore),
ezBookkeepingLogoPath() {
return assetConstants.ezBookkeepingLogoPath;
},
currentNotificationContent() {
return this.rootStore.currentNotification;
}
},
watch: {
currentNotificationContent: function (newValue) {
this.showNotification = !!newValue;
}
},
created() {
const self = this;
const theme = useTheme();
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
import { ThemeType } from '@/core/theme.ts';
import { isProduction } from '@/lib/version.ts';
import { initMapProvider } from '@/lib/map/index.ts';
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
if (self.settingsStore.appSettings.theme === 'light') {
theme.global.name.value = 'light';
} else if (self.settingsStore.appSettings.theme === 'dark') {
theme.global.name.value = 'dark';
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
const theme = useTheme();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const tokensStore = useTokensStore();
const exchangeRatesStore = useExchangeRatesStore();
const showNotification = ref<boolean>(false);
const currentNotificationContent = computed<string | null>(() => rootStore.currentNotification);
onMounted(() => {
document.addEventListener('DOMContentLoaded', () => {
const languageInfo = getCurrentLanguageInfo();
initMapProvider(languageInfo?.alternativeLanguageTag);
});
});
watch(currentNotificationContent, (newValue) => {
showNotification.value = !!newValue;
});
if (settingsStore.appSettings.theme === ThemeType.Light) {
theme.global.name.value = ThemeType.Light;
} else if (settingsStore.appSettings.theme === ThemeType.Dark) {
theme.global.name.value = ThemeType.Dark;
} else {
theme.global.name.value = getSystemTheme();
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
if (settingsStore.appSettings.theme === 'auto') {
if (e.matches) {
theme.global.name.value = ThemeType.Dark;
} else {
theme.global.name.value = getSystemTheme();
theme.global.name.value = ThemeType.Light;
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
if (self.settingsStore.appSettings.theme === 'auto') {
if (e.matches) {
theme.global.name.value = 'dark';
} else {
theme.global.name.value = 'light';
}
}
});
let localeDefaultSettings = self.$locale.initLocale(self.userStore.currentUserLanguage, self.settingsStore.appSettings.timeZone);
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(self.userStore.currentUserExpenseAmountColor, self.userStore.currentUserIncomeAmountColor);
if (self.$user.isUserLogined()) {
if (!self.settingsStore.appSettings.applicationLock || self.$user.isUserUnlocked()) {
// refresh token if user is logined
self.tokensStore.refreshTokenAndRevokeOldToken().then(response => {
if (response.user) {
localeDefaultSettings = self.$locale.setLanguage(response.user.language);
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
if (response.notificationContent) {
self.rootStore.setNotificationContent(response.notificationContent);
}
}
});
// auto refresh exchange rates data
if (self.settingsStore.appSettings.autoUpdateExchangeRatesData) {
self.exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
}
}
}
if (isProduction()) {
register('./sw.js', {
registrationOptions: {
scope: './'
}
});
}
},
mounted() {
document.addEventListener('DOMContentLoaded', () => {
const languageInfo = this.$locale.getCurrentLanguageInfo();
loadMapAssets(languageInfo ? languageInfo.alternativeLanguageTag : null);
});
}
});
let localeDefaultSettings = initLocale(userStore.currentUserLanguage, settingsStore.appSettings.timeZone);
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor);
if (isUserLogined()) {
if (!settingsStore.appSettings.applicationLock || isUserUnlocked()) {
// refresh token if user is logined
tokensStore.refreshTokenAndRevokeOldToken().then(response => {
if (response.user) {
localeDefaultSettings = setLanguage(response.user.language);
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
if (response.notificationContent) {
rootStore.setNotificationContent(response.notificationContent);
}
}
});
// auto refresh exchange rates data
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
}
}
}
if (isProduction()) {
register('./sw.js', {
registrationOptions: {
scope: './'
}
});
}
</script>
+214 -208
View File
@@ -4,236 +4,242 @@
</f7-app>
</template>
<script>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import type { Framework7Parameters, Notification, Actions, Dialog, Popover, Popup, Sheet } from 'framework7/types';
import { f7ready } from 'framework7-vue';
import routes from './router/mobile.js';
import routes from './router/mobile.ts';
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import { useTokensStore } from '@/stores/token.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import { useI18n } from '@/locales/helpers.ts';
import assetConstants from '@/consts/asset.js';
import { isProduction } from '@/lib/version.js';
import { getTheme, isEnableAnimate } from '@/lib/settings.js';
import { loadMapAssets } from '@/lib/map/index.js';
import { setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
import { isModalShowing, setAppFontSize } from '@/lib/ui.mobile.js';
import { useRootStore } from '@/stores/index.ts';
import { useSettingsStore } from '@/stores/setting.ts';
import { useEnvironmentsStore } from '@/stores/environment.ts';
import { useUserStore } from '@/stores/user.ts';
import { useTokensStore } from '@/stores/token.ts';
import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
export default {
data() {
const self = this;
let darkMode = 'auto';
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
import { ThemeType } from '@/core/theme.ts';
import { isProduction } from '@/lib/version.ts';
import { getTheme, isEnableAnimate } from '@/lib/settings.ts';
import { initMapProvider } from '@/lib/map/index.ts';
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
import { isModalShowing, setAppFontSize } from '@/lib/ui/mobile.ts';
if (getTheme() === 'light') {
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const environmentsStore = useEnvironmentsStore();
const userStore = useUserStore();
const tokensStore = useTokensStore();
const exchangeRatesStore = useExchangeRatesStore();
const f7params = ref<Framework7Parameters>({
name: 'ezBookkeeping',
theme: 'ios',
colors: {
primary: '#c67e48'
},
routes: routes,
darkMode: (() => {
let darkMode: boolean | string = 'auto';
if (getTheme() === ThemeType.Light) {
darkMode = false;
} else if (getTheme() === 'dark') {
} else if (getTheme() === ThemeType.Dark) {
darkMode = true;
}
return {
notification: null,
f7params: {
name: 'ezBookkeeping',
theme: 'ios',
colors: {
primary: '#c67e48'
},
routes: routes,
darkMode: darkMode,
touch: {
disableContextMenu: true,
tapHold: true
},
serviceWorker: {
path: isProduction() ? './sw.js' : undefined,
scope: './',
},
actions: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true
},
dialog: {
animate: isEnableAnimate(),
backdrop: true
},
popover: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true
},
popup: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true,
swipeToClose: true
},
sheet: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true
},
smartSelect: {
routableModals: false
},
view: {
animate: isEnableAnimate(),
browserHistory: !self.isiOSHomeScreenMode(),
browserHistoryInitialMatch: true,
browserHistoryAnimate: false,
iosSwipeBackAnimateShadow: false,
mdSwipeBackAnimateShadow: false
}
},
isDarkMode: undefined,
hasPushPopupBackdrop: undefined,
hasBackdrop: undefined
}
return darkMode;
})(),
touch: {
disableContextMenu: true,
tapHold: true
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUserStore, useTokensStore, useExchangeRatesStore),
currentNotificationContent() {
return this.rootStore.currentNotification;
}
serviceWorker: {
path: isProduction() ? './sw.js' : undefined,
scope: './',
},
watch: {
currentNotificationContent: function (newValue) {
const self = this;
actions: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true
},
dialog: {
// @ts-expect-error there is an "animate" field in dialog parameters, but it is not declared in the type definition file
animate: isEnableAnimate(),
backdrop: true
},
popover: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true
},
popup: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true,
swipeToClose: true
},
sheet: {
animate: isEnableAnimate(),
backdrop: true,
closeOnEscape: true
},
smartSelect: {
routableModals: false
},
view: {
animate: isEnableAnimate(),
browserHistory: !isiOSHomeScreenMode(),
browserHistoryInitialMatch: true,
browserHistoryAnimate: false,
iosSwipeBackAnimateShadow: false,
mdSwipeBackAnimateShadow: false
}
});
if (self.notification) {
self.notification.close();
self.notification.destroy();
self.notification = null;
const notification = ref<Notification.Notification | null>(null);
const hasPushPopupBackdrop = ref<boolean | undefined>(undefined);
const hasBackdrop = ref<boolean | undefined>(undefined);
const currentNotificationContent = computed<string | null>(() => rootStore.currentNotification);
function isiOSHomeScreenMode(): boolean {
if ((/iphone|ipod|ipad/gi).test(navigator.platform) && (/Safari/i).test(navigator.appVersion) &&
window.matchMedia && window.matchMedia('(display-mode: standalone)').matches
) {
return true;
}
return false;
}
function setThemeColorMeta(darkMode: boolean | undefined): void {
if (hasPushPopupBackdrop.value) {
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#000');
return;
}
if (darkMode) {
if (hasBackdrop.value) {
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#0b0b0b');
} else {
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#121212');
}
} else {
if (hasBackdrop.value) {
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#949495');
} else {
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#f6f6f8');
}
}
}
function onBackdropChanged(element: { push?: boolean, opened?: boolean }): void {
if (element.push) {
hasPushPopupBackdrop.value = element.opened;
} else {
hasBackdrop.value = element.opened;
}
setThemeColorMeta(environmentsStore.framework7DarkMode);
}
onMounted(() => {
setAppFontSize(settingsStore.appSettings.fontSize);
f7ready((f7) => {
environmentsStore.framework7DarkMode = f7.darkMode;
setThemeColorMeta(f7.darkMode);
f7.on('actionsOpen', (actions: Actions.Actions) => onBackdropChanged(actions));
f7.on('actionsClose', (actions: Actions.Actions) => onBackdropChanged(actions));
f7.on('dialogOpen', (dialog: Dialog.Dialog) => onBackdropChanged(dialog));
f7.on('dialogClose', (dialog: Dialog.Dialog) => onBackdropChanged(dialog));
f7.on('popoverOpen', (popover: Popover.Popover) => onBackdropChanged(popover));
f7.on('popoverClose', (popover: Popover.Popover) => onBackdropChanged(popover));
f7.on('popupOpen', (popup: Popup.Popup) => onBackdropChanged(popup));
f7.on('popupClose', (popup: Popup.Popup) => onBackdropChanged(popup));
f7.on('sheetOpen', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
f7.on('sheetClose', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
f7.on('pageBeforeOut', () => {
if (isModalShowing()) {
f7.actions.close('.actions-modal.modal-in', false);
f7.dialog.close('.dialog.modal-in', false);
f7.popover.close('.popover.modal-in', false);
f7.popup.close('.popup.modal-in', false);
f7.sheet.close('.sheet-modal.modal-in', false);
}
});
if (newValue) {
f7ready((f7) => {
self.notification = f7.notification.create({
icon: `<img alt="logo" src="${assetConstants.ezBookkeepingLogoPath}" />`,
title: self.$t('global.app.title'),
text: newValue,
closeOnClick: true,
on: {
close() {
self.rootStore.setNotificationContent(null);
}
}
}).open();
});
}
}
},
created() {
const self = this;
f7.on('darkModeChange', (darkMode) => {
environmentsStore.framework7DarkMode = darkMode;
setThemeColorMeta(darkMode);
});
});
let localeDefaultSettings = self.$locale.initLocale(self.userStore.currentUserLanguage, self.settingsStore.appSettings.timeZone);
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
document.addEventListener('DOMContentLoaded', () => {
const languageInfo = getCurrentLanguageInfo();
initMapProvider(languageInfo?.alternativeLanguageTag);
});
});
setExpenseAndIncomeAmountColor(self.userStore.currentUserExpenseAmountColor, self.userStore.currentUserIncomeAmountColor);
if (self.$user.isUserLogined()) {
if (!self.settingsStore.appSettings.applicationLock || self.$user.isUserUnlocked()) {
// refresh token if user is logined
self.tokensStore.refreshTokenAndRevokeOldToken().then(response => {
if (response.user) {
localeDefaultSettings = self.$locale.setLanguage(response.user.language);
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
if (response.notificationContent) {
self.rootStore.setNotificationContent(response.notificationContent);
}
}
});
// auto refresh exchange rates data
if (self.settingsStore.appSettings.autoUpdateExchangeRatesData) {
self.exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
}
}
}
},
mounted() {
setAppFontSize(this.settingsStore.appSettings.fontSize);
watch(currentNotificationContent, (newValue) => {
if (notification.value) {
notification.value.close();
// @ts-expect-error there is an "destroy" function in the Notification component, but it is not defined in the type definition file
// see https://framework7.io/docs/notification
notification.value.destroy();
notification.value = null;
}
if (newValue) {
f7ready((f7) => {
this.isDarkMode = f7.darkMode;
this.setThemeColorMeta(f7.darkMode);
f7.on('actionsOpen', (event) => this.onBackdropChanged(event));
f7.on('actionsClose', (event) => this.onBackdropChanged(event));
f7.on('dialogOpen', (event) => this.onBackdropChanged(event));
f7.on('dialogClose', (event) => this.onBackdropChanged(event));
f7.on('popoverOpen', (event) => this.onBackdropChanged(event));
f7.on('popoverClose', (event) => this.onBackdropChanged(event));
f7.on('popupOpen', (event) => this.onBackdropChanged(event));
f7.on('popupClose', (event) => this.onBackdropChanged(event));
f7.on('sheetOpen', (event) => this.onBackdropChanged(event));
f7.on('sheetClose', (event) => this.onBackdropChanged(event));
f7.on('pageBeforeOut', () => {
if (isModalShowing()) {
f7.actions.close('.actions-modal.modal-in', false);
f7.dialog.close('.dialog.modal-in', false);
f7.popover.close('.popover.modal-in', false);
f7.popup.close('.popup.modal-in', false);
f7.sheet.close('.sheet-modal.modal-in', false);
notification.value = f7.notification.create({
icon: `<img alt="logo" src="${APPLICATION_LOGO_PATH}" />`,
title: tt('global.app.title'),
text: newValue,
closeOnClick: true,
on: {
close() {
rootStore.setNotificationContent(null);
}
}
});
}).open();
});
}
});
f7.on('darkModeChange', (isDarkMode) => {
this.isDarkMode = isDarkMode;
this.setThemeColorMeta(isDarkMode);
});
let localeDefaultSettings = initLocale(userStore.currentUserLanguage, settingsStore.appSettings.timeZone);
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor);
if (isUserLogined()) {
if (!settingsStore.appSettings.applicationLock || isUserUnlocked()) {
// refresh token if user is logined
tokensStore.refreshTokenAndRevokeOldToken().then(response => {
if (response.user) {
localeDefaultSettings = setLanguage(response.user.language);
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
if (response.notificationContent) {
rootStore.setNotificationContent(response.notificationContent);
}
}
});
document.addEventListener('DOMContentLoaded', () => {
const languageInfo = this.$locale.getCurrentLanguageInfo();
loadMapAssets(languageInfo ? languageInfo.alternativeLanguageTag : null);
});
},
methods: {
isiOSHomeScreenMode() {
if ((/iphone|ipod|ipad/gi).test(navigator.platform) && (/Safari/i).test(navigator.appVersion) &&
window.matchMedia && window.matchMedia('(display-mode: standalone)').matches
) {
return true;
}
return false;
},
onBackdropChanged(event) {
if (event.push) {
this.hasPushPopupBackdrop = event.opened;
} else {
this.hasBackdrop = event.opened;
}
this.setThemeColorMeta(this.isDarkMode);
},
setThemeColorMeta(isDarkMode) {
if (this.hasPushPopupBackdrop) {
document.querySelector('meta[name=theme-color]').setAttribute('content', '#000');
return;
}
if (isDarkMode) {
if (this.hasBackdrop) {
document.querySelector('meta[name=theme-color]').setAttribute('content', '#0b0b0b');
} else {
document.querySelector('meta[name=theme-color]').setAttribute('content', '#121212');
}
} else {
if (this.hasBackdrop) {
document.querySelector('meta[name=theme-color]').setAttribute('content', '#949495');
} else {
document.querySelector('meta[name=theme-color]').setAttribute('content', '#f6f6f8');
}
}
// auto refresh exchange rates data
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
}
}
}
@@ -0,0 +1,138 @@
import { ref, computed } from 'vue';
import { type TimeRangeAndDateType, type PresetDateRange, type UnixTimeRange, DateRange } from '@/core/datetime.ts';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.ts';
import {
getCurrentUnixTime,
getCurrentYear,
getUnixTime,
getLocalDatetimeFromUnixTime,
getTodayFirstUnixTime,
getDummyUnixTimeForLocalUsage,
getActualUnixTimeForStore,
getTimezoneOffsetMinutes,
getBrowserTimezoneOffsetMinutes,
getDateRangeByDateType
} from '@/lib/datetime.ts';
import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
export interface CommonDateRangeSelectionProps {
minTime?: number;
maxTime?: number;
title?: string;
hint?: string;
show: boolean;
}
function getDateRangeFromProps(props: CommonDateRangeSelectionProps): { minDate: number; maxDate: number } {
let minDate = getTodayFirstUnixTime();
let maxDate = getCurrentUnixTime();
if (props.minTime) {
minDate = props.minTime;
}
if (props.maxTime) {
maxDate = props.maxTime;
}
return {
minDate,
maxDate
};
}
export function useDateRangeSelectionBase(props: CommonDateRangeSelectionProps) {
const { tt, getAllMinWeekdayNames, formatUnixTimeToLongDateTime, isLongDateMonthAfterYear, isLongTime24HourFormat } = useI18n();
const userStore = useUserStore();
const { minDate, maxDate } = getDateRangeFromProps(props);
const yearRange = ref<number[]>([
2000,
getCurrentYear() + 1
]);
const dateRange = ref<Date[]>([
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(minDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(maxDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]);
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
const dayNames = computed<string[]>(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value));
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
const is24Hour = computed<boolean>(() => isLongTime24HourFormat());
const beginDateTime = computed<string>(() => {
const actualBeginUnixTime = getActualUnixTimeForStore(getUnixTime(dateRange.value[0]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return formatUnixTimeToLongDateTime(actualBeginUnixTime);
});
const endDateTime = computed<string>(() => {
const actualEndUnixTime = getActualUnixTimeForStore(getUnixTime(dateRange.value[1]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return formatUnixTimeToLongDateTime(actualEndUnixTime);
});
const presetRanges = computed<PresetDateRange[]>(() => {
const presetRanges:PresetDateRange[] = [];
[
DateRange.Today,
DateRange.LastSevenDays,
DateRange.LastThirtyDays,
DateRange.ThisWeek,
DateRange.ThisMonth,
DateRange.ThisYear
].forEach(dateRangeType => {
const dateRange = getDateRangeByDateType(dateRangeType.type, firstDayOfWeek.value) as TimeRangeAndDateType;
presetRanges.push({
label: tt(dateRangeType.name),
value: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.minTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.maxTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
});
});
return presetRanges;
});
function getFinalDateRange(): UnixTimeRange | null {
if (!dateRange.value[0] || !dateRange.value[1]) {
return null;
}
const currentMinDate = dateRange.value[0];
const currentMaxDate = dateRange.value[1];
let minUnixTime = getUnixTime(currentMinDate);
let maxUnixTime = getUnixTime(currentMaxDate);
if (minUnixTime < 0 || maxUnixTime < 0) {
throw new Error('Date is too early');
}
minUnixTime = getActualUnixTimeForStore(minUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
maxUnixTime = getActualUnixTimeForStore(maxUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return {
minUnixTime,
maxUnixTime
};
}
return {
// states
yearRange,
dateRange,
// computed states
dayNames,
isYearFirst,
is24Hour,
beginDateTime,
endDateTime,
presetRanges,
// functions
getFinalDateRange
};
}
+134
View File
@@ -0,0 +1,134 @@
import { computed } from 'vue';
import type { ColorValue } from '@/core/color.ts';
import { ALL_ACCOUNT_ICONS, DEFAULT_ACCOUNT_ICON, ALL_CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from '@/consts/icon.ts';
import { DEFAULT_ICON_COLOR, DEFAULT_ACCOUNT_COLOR, DEFAULT_CATEGORY_COLOR } from '@/consts/color.ts';
import { isNumber } from '@/lib/common.ts';
type IconItemStyleName = string;
type IconItemStyleValue = ColorValue | string | number | undefined;
type CommonIconItemType = 'account' | 'category' | 'fixed';
type MobileIconItemType = 'fixed-f7';
export interface CommonIconProps {
iconType: CommonIconItemType | MobileIconItemType;
iconId: string | number;
color?: ColorValue;
defaultColor?: ColorValue;
additionalColorAttr?: string;
size?: string | number;
}
export function useItemIconBase(props: CommonIconProps) {
const style = computed<Record<IconItemStyleName, IconItemStyleValue>>(() => {
let defaultColor = 'var(--default-icon-color)';
if (props.defaultColor) {
defaultColor = props.defaultColor;
}
if (props.iconType === 'account') {
return getAccountIconStyle(props.color, defaultColor, props.additionalColorAttr);
} else if (props.iconType === 'category') {
return getCategoryIconStyle(props.color, defaultColor, props.additionalColorAttr);
} else {
return getDefaultIconStyle(props.color, defaultColor, props.additionalColorAttr);
}
});
function getAccountIcon(iconId: string | number): string {
if (isNumber(iconId)) {
iconId = iconId.toString();
}
if (!ALL_ACCOUNT_ICONS[iconId]) {
return DEFAULT_ACCOUNT_ICON.icon;
}
return ALL_ACCOUNT_ICONS[iconId].icon;
}
function getCategoryIcon(iconId: string | number): string {
if (isNumber(iconId)) {
iconId = iconId.toString();
}
if (!ALL_CATEGORY_ICONS[iconId]) {
return DEFAULT_CATEGORY_ICON.icon;
}
return ALL_CATEGORY_ICONS[iconId].icon;
}
function getAccountIconStyle(color?: ColorValue | string, defaultColor?: ColorValue | string, additionalColorAttr?: string): Record<IconItemStyleName, IconItemStyleValue> {
if (color && color !== DEFAULT_ACCOUNT_COLOR) {
color = '#' + color;
} else {
color = defaultColor;
}
const ret: Record<IconItemStyleName, IconItemStyleValue> = {
color: color
};
if (additionalColorAttr) {
ret[additionalColorAttr] = color;
}
if (props.size) {
ret['font-size'] = props.size;
}
return ret;
}
function getCategoryIconStyle(color?: ColorValue | string, defaultColor?: ColorValue | string, additionalColorAttr?: string): Record<IconItemStyleName, IconItemStyleValue> {
if (color && color !== DEFAULT_CATEGORY_COLOR) {
color = '#' + color;
} else {
color = defaultColor;
}
const ret: Record<IconItemStyleName, IconItemStyleValue> = {
color: color
};
if (additionalColorAttr) {
ret[additionalColorAttr] = color;
}
if (props.size) {
ret['font-size'] = props.size;
}
return ret;
}
function getDefaultIconStyle(color?: ColorValue | string, defaultColor?: ColorValue | string, additionalColorAttr?: string): Record<IconItemStyleName, IconItemStyleValue> {
if (color && color !== DEFAULT_ICON_COLOR) {
color = '#' + color;
} else {
color = defaultColor;
}
const ret: Record<IconItemStyleName, IconItemStyleValue> = {
color: color
};
if (additionalColorAttr) {
ret[additionalColorAttr] = color;
}
if (props.size) {
ret['font-size'] = props.size;
}
return ret;
}
return {
style,
getAccountIcon,
getCategoryIcon
}
}
+73
View File
@@ -0,0 +1,73 @@
import { computed } from 'vue';
import type { LanguageOption } from '@/locales/index.ts';
import { useI18n } from '@/locales/helpers.ts';
import { useSettingsStore } from '@/stores/setting.ts';
export interface LanguageSelectBaseProps {
disabled?: boolean;
includeSystemDefault?: boolean;
useModelValue?: boolean;
modelValue?: string;
}
export interface LanguageSelectBaseEmits {
(e: 'update:modelValue', value: string): void;
}
export function useLanguageSelectButtonBase(props: LanguageSelectBaseProps, emit: LanguageSelectBaseEmits) {
const { getCurrentLanguageTag, getCurrentLanguageDisplayName, getAllLanguageOptions, getLanguageInfo, setLanguage } = useI18n();
const settingsStore = useSettingsStore();
const allLanguages = computed<LanguageOption[]>(() => getAllLanguageOptions(!!props.includeSystemDefault));
const currentLocale = computed<string>({
get: () => getCurrentLanguageTag(),
set: (value: string) => {
const localeDefaultSettings = setLanguage(value);
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
}
});
const currentLanguageName = computed<string>(() => {
if (props.useModelValue && props.modelValue) {
const languageInfo = getLanguageInfo(props.modelValue);
if (!languageInfo) {
return '';
}
return languageInfo.displayName;
} else {
return getCurrentLanguageDisplayName()
}
});
function updateLanguage(languageTag: string): void {
if (props.useModelValue) {
emit('update:modelValue', languageTag);
} else {
currentLocale.value = languageTag;
}
}
function isLanguageSelected(languageTag: string): boolean {
if (props.useModelValue) {
return props.modelValue === languageTag;
} else {
return currentLocale.value === languageTag;
}
}
return {
// computed states
allLanguages,
currentLocale,
currentLanguageName,
// functions
updateLanguage,
isLanguageSelected
}
}
@@ -0,0 +1,99 @@
import { ref, computed } from 'vue';
import type { YearMonth } from '@/core/datetime.ts';
import {
getYearMonthObjectFromUnixTime,
getYearMonthObjectFromString,
getYearMonthStringFromObject,
getCurrentUnixTime,
getCurrentYear,
getThisYearFirstUnixTime,
getYearMonthFirstUnixTime,
getYearMonthLastUnixTime
} from '@/lib/datetime.ts';
import { useI18n } from '@/locales/helpers.ts';
export interface CommonMonthRangeSelectionProps {
minTime?: string;
maxTime?: string;
title?: string;
hint?: string;
show: boolean;
}
function getMonthRangeFromProps(props: CommonMonthRangeSelectionProps): { minDate: YearMonth; maxDate: YearMonth } {
let minDate: YearMonth = getYearMonthObjectFromUnixTime(getThisYearFirstUnixTime());
let maxDate: YearMonth = getYearMonthObjectFromUnixTime(getCurrentUnixTime());
if (props.minTime) {
const yearMonth = getYearMonthObjectFromString(props.minTime);
if (yearMonth) {
minDate = yearMonth;
}
}
if (props.maxTime) {
const yearMonth = getYearMonthObjectFromString(props.maxTime);
if (yearMonth) {
maxDate = yearMonth;
}
}
return {
minDate,
maxDate
};
}
export function useMonthRangeSelectionBase(props: CommonMonthRangeSelectionProps) {
const { formatUnixTimeToLongYearMonth, isLongDateMonthAfterYear } = useI18n();
const { minDate, maxDate } = getMonthRangeFromProps(props);
const yearRange = ref<number[]>([
2000,
getCurrentYear() + 1
]);
const dateRange = ref<YearMonth[]>([
minDate,
maxDate
]);
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
const beginDateTime = computed<string>(() => formatUnixTimeToLongYearMonth(getYearMonthFirstUnixTime(dateRange.value[0])));
const endDateTime = computed<string>(() => formatUnixTimeToLongYearMonth(getYearMonthLastUnixTime(dateRange.value[1])));
function getFinalMonthRange(): { minYearMonth: string, maxYearMonth: string } | null {
if (!dateRange.value[0] || !dateRange.value[1]) {
return null;
}
if (dateRange.value[0].year <= 0 || dateRange.value[0].month < 0 || dateRange.value[1].year <= 0 || dateRange.value[1].month < 0) {
throw new Error('Date is too early');
}
const minYearMonth = getYearMonthStringFromObject(dateRange.value[0]);
const maxYearMonth = getYearMonthStringFromObject(dateRange.value[1]);
return {
minYearMonth,
maxYearMonth
};
}
return {
// states
yearRange,
dateRange,
// computed states
isYearFirst,
beginDateTime,
endDateTime,
// functions
getFinalMonthRange
};
}
+96
View File
@@ -0,0 +1,96 @@
import { ref, computed, watch } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
import { isNumber } from '@/lib/common.ts';
import { formatPercent } from '@/lib/numeral.ts';
export interface CommonPieChartDataItem {
id: string;
name: string;
displayName: string;
value: number;
percent: number;
actualPercent: number;
color: string;
sourceItem: Record<string, unknown>;
displayPercent?: string;
displayValue?: string;
}
export interface CommonPieChartProps {
skeleton?: boolean;
items: Record<string, unknown>[];
idField?: string;
nameField: string;
valueField: string;
percentField?: string;
colorField?: string;
hiddenField?: string;
minValidPercent?: number;
defaultCurrency?: string;
showValue?: boolean;
enableClickItem?: boolean;
}
export function usePieChartBase(props: CommonPieChartProps) {
const { formatAmountWithCurrency } = useI18n();
const selectedIndex = ref<number>(0);
const validItems = computed<CommonPieChartDataItem[]>(() => {
let totalValidValue = 0;
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i];
const value = item[props.valueField];
if (isNumber(value) && value > 0 && (!props.hiddenField || !item[props.hiddenField])) {
totalValidValue += value;
}
}
const validItems: CommonPieChartDataItem[] = [];
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i];
const value = item[props.valueField];
const percent = props.percentField ? item[props.percentField] : -1;
if (isNumber(value) && value > 0 &&
(!props.hiddenField || !item[props.hiddenField]) &&
(!props.minValidPercent || value / totalValidValue > props.minValidPercent)) {
const finalItem: CommonPieChartDataItem = {
id: (props.idField && item[props.idField]) ? item[props.idField] as string : item[props.nameField] as string,
name: (props.idField && item[props.idField]) ? item[props.idField] as string : item[props.nameField] as string,
displayName: item[props.nameField] as string,
value: value,
percent: (isNumber(percent) && percent >= 0) ? percent : (value / totalValidValue * 100),
actualPercent: value / totalValidValue,
color: (props.colorField && item[props.colorField]) ? item[props.colorField] as string : DEFAULT_CHART_COLORS[validItems.length % DEFAULT_CHART_COLORS.length],
sourceItem: item
};
finalItem.displayPercent = formatPercent(finalItem.percent, 2, '&lt;0.01');
finalItem.displayValue = formatAmountWithCurrency(finalItem.value, props.defaultCurrency);
validItems.push(finalItem);
}
}
return validItems;
});
watch(() => props.items, () => {
selectedIndex.value = 0;
});
return {
// states
selectedIndex,
// computed states
validItems
};
}
@@ -0,0 +1,64 @@
import { computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
import type { TypeAndDisplayName } from '@/core/base.ts';
import { sortNumbersArray } from '@/lib/common.ts';
export interface CommonScheduleFrequencySelectionProps {
type: number;
modelValue: string;
disabled?: boolean;
readonly?: boolean;
label?: string;
}
export interface AvailableMonthDay {
day: number;
displayName: string;
}
export function useScheduleFrequencySelectionBase() {
const { getAllWeekDays, getAllTransactionScheduledFrequencyTypes, getMonthdayShortName } = useI18n();
const userStore = useUserStore();
const allTransactionScheduledFrequencyTypes = computed<TypeAndDisplayName[]>(() => getAllTransactionScheduledFrequencyTypes());
const allWeekDays = computed<TypeAndDisplayName[]>(() => getAllWeekDays(userStore.currentUserFirstDayOfWeek));
const allAvailableMonthDays = computed<AvailableMonthDay[]>(() => {
const allAvailableDays = [];
for (let i = 1; i <= 28; i++) {
allAvailableDays.push({
day: i,
displayName: getMonthdayShortName(i),
});
}
return allAvailableDays;
});
function getFrequencyValues(value: string): number[] {
const values = value.split(',');
const ret: number[] = [];
for (let i = 0; i < values.length; i++) {
if (values[i]) {
ret.push(parseInt(values[i]));
}
}
return sortNumbersArray(ret);
}
return {
// computed states
allTransactionScheduledFrequencyTypes,
allWeekDays,
allAvailableMonthDays,
// functions
getFrequencyValues
};
}
+64
View File
@@ -0,0 +1,64 @@
import { computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import type {
YearMonth,
TimeRangeAndDateType,
YearUnixTime,
YearQuarterUnixTime,
YearMonthUnixTime
} from '@/core/datetime.ts';
import type { ColorValue } from '@/core/color.ts';
import { DEFAULT_ICON_COLOR } from '@/consts/color.ts';
import type { YearMonthItems } from '@/models/transaction.ts';
import { getAllDateRanges } from '@/lib/statistics.ts';
export interface CommonTrendsChartProps<T extends YearMonth> {
items: YearMonthItems<T>[];
startYearMonth: string;
endYearMonth: string;
sortingType: number;
dateAggregationType: number;
idField?: string;
nameField: string;
valueField: string;
colorField?: string;
hiddenField?: string;
displayOrdersField?: string;
translateName?: boolean;
defaultCurrency?: string;
enableClickItem?: boolean;
}
export interface TrendsBarChartClickEvent {
itemId: string;
dateRange: TimeRangeAndDateType;
}
export function useTrendsChartBase<T extends YearMonth>(props: CommonTrendsChartProps<T>) {
const { tt } = useI18n();
const allDateRanges = computed<YearUnixTime[] | YearQuarterUnixTime[] | YearMonthUnixTime[]>(() => getAllDateRanges(props.items, props.startYearMonth, props.endYearMonth, props.dateAggregationType));
function getItemName(name: string): string {
return props.translateName ? tt(name) : name;
}
function getColor(color: string): ColorValue {
if (color && color !== DEFAULT_ICON_COLOR) {
color = '#' + color;
}
return color;
}
return {
// computed states
allDateRanges,
// functions
getItemName,
getColor
}
}
@@ -0,0 +1,68 @@
import { type Ref } from 'vue';
import {
type TwoLevelItemSelectionBaseProps,
useTwoLevelItemSelectionBase
} from '@/components/base/TwoLevelItemSelectionBase.ts';
import { getItemByKeyValue, getPrimaryValueBySecondaryValue } from '@/lib/common.ts';
export interface CommonTwoColumnListItemSelectionProps extends TwoLevelItemSelectionBaseProps {
primaryValueField?: string;
primaryHeaderField?: string;
primaryHeaderI18n?: boolean;
primaryFooterField?: string;
primaryFooterI18n?: boolean;
secondaryHeaderField?: string;
secondaryHeaderI18n?: boolean;
secondaryFooterField?: string;
secondaryFooterI18n?: boolean;
}
export function useTwoColumnListItemSelectionBase(props: CommonTwoColumnListItemSelectionProps) {
const {
filterContent,
visibleItemsCount,
filteredItems,
getFilteredSubItems,
isSecondaryValueSelected,
getSelectedSecondaryItem,
updateCurrentSecondaryValue
} = useTwoLevelItemSelectionBase(props);
function getCurrentPrimaryValueBySecondaryValue(secondaryValue: unknown): unknown {
return getPrimaryValueBySecondaryValue(props.items as Record<string, Record<string, unknown>[]>[], props.primarySubItemsField, props.primaryValueField, props.primaryHiddenField, props.secondaryValueField, props.secondaryHiddenField, secondaryValue);
}
function getSelectedPrimaryItem(currentPrimaryValue: unknown): unknown {
if (props.primaryValueField) {
return getItemByKeyValue(props.items, currentPrimaryValue, props.primaryValueField);
} else {
return currentPrimaryValue;
}
}
function updateCurrentPrimaryValue(currentPrimaryValue: Ref<unknown>, item: unknown): void {
if (props.primaryValueField) {
currentPrimaryValue.value = (item as Record<string, unknown>)[props.primaryValueField];
} else {
currentPrimaryValue.value = item;
}
}
return {
// states
filterContent,
// computed states
visibleItemsCount,
filteredItems,
// functions
getFilteredSubItems,
getCurrentPrimaryValueBySecondaryValue,
isSecondaryValueSelected,
getSelectedPrimaryItem,
getSelectedSecondaryItem,
updateCurrentPrimaryValue,
updateCurrentSecondaryValue
};
}
@@ -0,0 +1,162 @@
import { type Ref, ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { getItemByKeyValue } from '@/lib/common.ts';
export interface TwoLevelItemSelectionBaseProps {
modelValue: unknown;
primaryKeyField?: string;
primaryTitleField?: string;
primaryTitleI18n?: boolean;
primaryIconField?: string;
primaryIconType?: string;
primaryColorField?: string;
primaryHiddenField?: string;
primarySubItemsField: string;
secondaryKeyField?: string;
secondaryValueField?: string;
secondaryTitleField?: string;
secondaryTitleI18n?: boolean;
secondaryIconField?: string;
secondaryIconType?: string;
secondaryColorField?: string;
secondaryHiddenField?: string;
enableFilter?: boolean;
filterPlaceholder?: string;
filterNoItemsText?: string;
items: Record<string, unknown>[];
}
export function useTwoLevelItemSelectionBase(props: TwoLevelItemSelectionBaseProps) {
const { ti } = useI18n();
const filterContent = ref<string>('');
const visibleItemsCount = computed<number>(() => {
let count = 0;
for (const item of props.items) {
if (props.primaryHiddenField && item[props.primaryHiddenField]) {
continue;
}
count++;
}
return count;
});
const filteredItems = computed<Record<string, unknown>[]>(() => {
const finalItems: Record<string, unknown>[] = [];
const items = props.items;
const lowerCaseFilterContent = filterContent.value?.toLowerCase() ?? '';
for (const item of items) {
if (props.primaryHiddenField && item[props.primaryHiddenField]) {
continue;
}
if (!props.enableFilter || !lowerCaseFilterContent) {
finalItems.push(item);
continue;
}
if (props.primaryTitleField) {
const title = ti(item[props.primaryTitleField] as string, !!props.primaryTitleI18n);
if (title.toLowerCase().indexOf(lowerCaseFilterContent) >= 0) {
finalItems.push(item);
continue;
}
}
if (props.primarySubItemsField) {
if (getFilteredSubItems(item).length > 0) {
finalItems.push(item);
}
}
}
return finalItems;
});
function getFilteredSubItems(selectedPrimaryItem: unknown): Record<string, unknown>[] {
const finalItems: Record<string, unknown>[] = [];
if (!selectedPrimaryItem || !props.primarySubItemsField) {
return finalItems;
}
const subItems = (selectedPrimaryItem as Record<string, unknown>)[props.primarySubItemsField] as Record<string, unknown>[];
let primaryTitleHasFilterContent = false;
if (props.primaryTitleField) {
const title = ti((selectedPrimaryItem as Record<string, unknown>)[props.primaryTitleField] as string, !!props.primaryTitleI18n);
primaryTitleHasFilterContent = title.toLowerCase().indexOf(filterContent.value.toLowerCase()) >= 0;
}
for (const subItem of subItems) {
if (props.secondaryHiddenField && subItem[props.secondaryHiddenField]) {
continue;
}
if (!props.enableFilter || !filterContent.value) {
finalItems.push(subItem);
continue;
}
if (primaryTitleHasFilterContent) {
finalItems.push(subItem);
continue;
}
if (props.secondaryTitleField && filterContent.value) {
const title = ti(subItem[props.secondaryTitleField] as string, !!props.secondaryTitleI18n);
if (title.toLowerCase().indexOf(filterContent.value.toLowerCase()) >= 0) {
finalItems.push(subItem);
}
}
}
return finalItems;
}
function isSecondaryValueSelected(currentSecondaryValue: unknown, subItem: unknown): boolean {
if (props.secondaryValueField) {
return currentSecondaryValue === (subItem as Record<string, unknown>)[props.secondaryValueField];
} else {
return currentSecondaryValue === subItem;
}
}
function getSelectedSecondaryItem(currentSecondaryValue: unknown, selectedPrimaryItem: unknown): unknown {
if (currentSecondaryValue && selectedPrimaryItem && (selectedPrimaryItem as Record<string, unknown>)[props.primarySubItemsField]) {
return getItemByKeyValue((selectedPrimaryItem as Record<string, unknown>)[props.primarySubItemsField] as Record<string, unknown>[], currentSecondaryValue, props.secondaryValueField as string);
} else {
return null;
}
}
function updateCurrentSecondaryValue(currentSecondaryValue: Ref<unknown>, subItem: unknown): void {
if (props.secondaryValueField) {
currentSecondaryValue.value = (subItem as Record<string, unknown>)[props.secondaryValueField];
} else {
currentSecondaryValue.value = subItem;
}
}
return {
// states
filterContent,
// computed states
visibleItemsCount,
filteredItems,
// functions
getFilteredSubItems,
isSecondaryValueSelected,
getSelectedSecondaryItem,
updateCurrentSecondaryValue
};
}
+93 -101
View File
@@ -8,112 +8,104 @@
v-if="!mapSupported || !mapDependencyLoaded"></slot>
</template>
<script>
import {
copyObjectTo
} from '@/lib/common.js';
import {
createMapHolder,
initMapInstance,
setMapCenterTo,
setMapCenterMarker,
removeMapCenterMarker
} from '@/lib/map/index.js';
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue';
export default {
props: [
'height',
'mapClass',
'mapStyle',
'geoLocation'
],
expose: [
'init'
],
data() {
this.mapHolder = createMapHolder();
import { useI18n } from '@/locales/helpers.ts';
return {
mapSupported: !!this.mapHolder,
mapDependencyLoaded: this.mapHolder && this.mapHolder.dependencyLoaded,
mapInited: false,
initCenter: {
latitude: 0,
longitude: 0,
},
zoomLevel: 1
import type { MapInstance, MapPosition } from '@/lib/map/base.ts';
import { createMapInstance } from '@/lib/map/index.ts';
const props = defineProps<{
height?: string;
mapClass?: string;
mapStyle?: Record<string, string>;
geoLocation?: MapPosition;
}>();
const { tt, getCurrentLanguageInfo } = useI18n();
const mapContainer = useTemplateRef<HTMLElement>('mapContainer');
const mapInstance = ref<MapInstance | null>(createMapInstance());
const initCenter = ref<MapPosition>({
latitude: 0,
longitude: 0
});
const zoomLevel = ref<number>(1);
const mapSupported = computed<boolean>(() => !!mapInstance.value);
const mapDependencyLoaded = computed<boolean>(() => mapInstance.value?.dependencyLoaded || false);
const finalMapStyle = computed<Record<string, unknown>>(() => {
const styles: Record<string, unknown> = Object.assign({}, props.mapStyle);
if (props.height) {
styles['height'] = props.height;
}
if (!mapSupported.value || !mapDependencyLoaded.value) {
styles['height'] = '0';
}
return styles;
});
function initMapView(): void {
let isFirstInit = false;
let centerChanged = false;
if (!mapSupported.value || !mapDependencyLoaded.value || !mapInstance.value) {
return;
}
if (props.geoLocation && (props.geoLocation.longitude || props.geoLocation.latitude)) {
if (initCenter.value.latitude !== props.geoLocation.latitude || initCenter.value.longitude !== props.geoLocation.longitude) {
initCenter.value.latitude = props.geoLocation.latitude;
initCenter.value.longitude = props.geoLocation.longitude;
zoomLevel.value = mapInstance.value.defaultZoomLevel;
centerChanged = true;
}
},
computed: {
finalMapStyle() {
const styles = copyObjectTo(this.mapStyle, {});
} else if (!props.geoLocation || (!props.geoLocation.longitude && !props.geoLocation.latitude)) {
if (initCenter.value.latitude || initCenter.value.longitude) {
initCenter.value.latitude = 0;
initCenter.value.longitude = 0;
zoomLevel.value = mapInstance.value.minZoomLevel;
if (this.height) {
styles.height = this.height;
}
if (!this.mapSupported || !this.mapDependencyLoaded) {
styles.height = '0';
}
return styles;
}
},
methods: {
init() {
let isFirstInit = false;
let centerChanged = false;
if (!this.mapSupported || !this.mapDependencyLoaded) {
return;
}
if (this.geoLocation && (this.geoLocation.longitude || this.geoLocation.latitude)) {
if (this.initCenter.latitude !== this.geoLocation.latitude || this.initCenter.longitude !== this.geoLocation.longitude) {
this.initCenter.latitude = this.geoLocation.latitude;
this.initCenter.longitude = this.geoLocation.longitude;
this.zoomLevel = this.mapHolder.defaultZoomLevel;
centerChanged = true;
}
} else if (!this.geoLocation || (!this.geoLocation.longitude && !this.geoLocation.latitude)) {
if (this.initCenter.latitude || this.initCenter.longitude) {
this.initCenter.latitude = 0;
this.initCenter.longitude = 0;
this.zoomLevel = this.mapHolder.minZoomLevel;
centerChanged = true;
}
}
if (!this.mapHolder.inited) {
const languageInfo = this.$locale.getCurrentLanguageInfo();
initMapInstance(this.mapHolder, this.$refs.mapContainer, {
language: languageInfo ? languageInfo.alternativeLanguageTag : null,
initCenter: this.initCenter,
zoomLevel: this.zoomLevel,
text: {
zoomIn: this.$t('Zoom in'),
zoomOut: this.$t('Zoom out'),
}
});
if (this.mapHolder.inited) {
isFirstInit = true;
}
}
if (isFirstInit || centerChanged) {
setMapCenterTo(this.mapHolder, this.initCenter, this.zoomLevel);
}
if (centerChanged && this.zoomLevel > this.mapHolder.minZoomLevel) {
setMapCenterMarker(this.mapHolder, this.initCenter);
} else if (centerChanged && this.zoomLevel <= this.mapHolder.minZoomLevel) {
removeMapCenterMarker(this.mapHolder);
}
centerChanged = true;
}
}
if (!mapInstance.value.inited) {
const languageInfo = getCurrentLanguageInfo();
mapInstance.value.initMapInstance(mapContainer.value as HTMLElement, {
language: languageInfo?.alternativeLanguageTag,
initCenter: initCenter.value,
zoomLevel: zoomLevel.value,
text: {
zoomIn: tt('Zoom in'),
zoomOut: tt('Zoom out'),
}
});
if (mapInstance.value.inited) {
isFirstInit = true;
}
}
if (isFirstInit || centerChanged) {
mapInstance.value.setMapCenterTo(initCenter.value, zoomLevel.value);
}
if (centerChanged && zoomLevel.value > mapInstance.value.minZoomLevel) {
mapInstance.value.setMapCenterMarker(initCenter.value);
} else if (centerChanged && zoomLevel.value <= mapInstance.value.minZoomLevel) {
mapInstance.value.removeMapCenterMarker();
}
}
defineExpose({
initMapView
});
</script>
+243 -230
View File
@@ -3,8 +3,7 @@
<div class="pin-code-input pin-code-input-outline"
:class="{ 'pin-code-input-focued': codes[index].focused }" :key="index"
v-for="(code, index) in codes">
<input min="0" maxlength="1" pattern="[0-9]*"
:ref="`pin-code-input-${index}`"
<input ref="pin-code-input" min="0" maxlength="1" pattern="[0-9]*"
:value="codes[index].value"
:type="codes[index].inputType"
:disabled="disabled ? 'disabled' : undefined"
@@ -19,243 +18,257 @@
</div>
</template>
<script>
export default {
props: [
'modelValue',
'disabled',
'autofocus',
'secure',
'length'
],
emits: [
'update:modelValue',
'pincode:confirm'
],
data() {
return {
codes: []
}
},
computed: {
finalPinCode() {
let finalPinCode = '';
<script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue';
for (let i = 0; i < this.codes.length; i++) {
if (this.codes[i].value) {
finalPinCode += this.codes[i].value;
} else {
break;
}
}
interface PinCode {
value: string;
inputType: string;
inputTimer: unknown | null;
focused: boolean;
}
return finalPinCode;
}
},
watch: {
'length': function (newValue) {
this.init(newValue, this.modelValue);
},
'modelValue': function (newValue) {
if (newValue === this.finalPinCode) {
return;
}
const props = defineProps<{
modelValue: string;
length: number;
disabled?: boolean;
autofocus?: boolean;
autoConfirm?: boolean;
secure?: boolean;
}>();
this.init(this.length, newValue);
},
'codes': {
handler() {
this.$emit('update:modelValue', this.finalPinCode);
},
deep: true
}
},
created() {
this.init(this.length, this.modelValue);
},
methods: {
init(length, value) {
this.codes.length = 0;
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'pincode:confirm', value: string): void;
}>();
for (let i = 0; i < length; i++) {
const code = {
value: '',
inputType: 'tel',
inputTimer: null,
focused: false
};
const codes = ref<PinCode[]>([]);
const pinCodeInputs = useTemplateRef<HTMLInputElement[]>('pin-code-input');
if (value && value[i]) {
code.value = value[i];
const finalPinCode = computed<string>(() => {
let ret = '';
if (this.secure) {
code.inputType = 'password';
}
}
this.codes.push(code);
}
},
autoFillText(index, text) {
let lastIndex = index;
for (let i = index, j = 0; i < this.codes.length && j < text.length; i++, j++) {
if (text[j] < '0' || text[j] > '9') {
this.codes[i].value = '';
this.$forceUpdate();
break;
}
this.codes[i].value = text[j];
this.setInputType(i);
lastIndex = i;
}
this.setFocus(lastIndex);
if (this.finalPinCode.length === this.length) {
this.$emit('pincode:confirm', this.finalPinCode);
}
},
setInputType(index) {
const self = this;
if (!self.secure) {
return;
}
if (!self.codes[index].value) {
self.codes[index].inputType = 'tel';
return;
}
if (self.codes[index].inputTimer) {
return;
}
self.codes[index].inputTimer = setTimeout(() => {
if (self.codes[index].value) {
self.codes[index].inputType = 'password';
} else {
self.codes[index].inputType = 'tel';
}
self.codes[index].inputTimer = null;
}, 300);
},
setFocus(index) {
const refId = `pin-code-input-${index}`;
const ref = this.$refs[refId];
if (ref && ref[0]) {
ref[0].focus();
ref[0].select();
}
},
setPreviousFocus(index) {
if (index > 0) {
this.setFocus(index - 1);
}
},
setNextFocus(index) {
if (index < this.length - 1) {
this.setFocus(index + 1);
}
},
onKeydown(index, event) {
if (event.altKey || (event.key.indexOf('F') === 0 && (event.key.length === 2 || event.key.length === 3))) {
return;
}
if (event.key === 'Enter' && this.finalPinCode.length === this.length) {
this.$emit('pincode:confirm', this.finalPinCode);
event.preventDefault();
return;
}
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
this.setPreviousFocus(index);
event.preventDefault();
return;
}
if (event.key === 'ArrowRight' || (!event.shiftKey && event.key === 'Tab')) {
this.setNextFocus(index);
event.preventDefault();
return;
}
if (event.key === 'Home') {
this.setFocus(0);
event.preventDefault();
return;
}
if (event.key === 'End') {
this.setFocus(this.length - 1);
event.preventDefault();
return;
}
if (((event.ctrlKey || event.metaKey) && event.key === 'v') || event.key === 'Paste') {
return;
}
if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Del') {
for (let i = index; i < this.codes.length; i++) {
this.codes[i].value = '';
this.setInputType(i);
}
if (event.code === 'Backspace') {
this.setPreviousFocus(index);
}
event.preventDefault();
return;
}
if (event.key.length === 1 && '0' <= event.key && event.key <= '9') {
this.codes[index].value = event.key;
this.setInputType(index);
this.setNextFocus(index);
if (this.finalPinCode.length === this.length) {
this.$emit('pincode:confirm', this.finalPinCode);
}
}
event.preventDefault();
},
onPaste(index, event) {
if (!event.clipboardData) {
event.preventDefault();
return;
}
const text = event.clipboardData.getData('Text');
if (!text) {
event.preventDefault();
return;
}
this.autoFillText(index, text);
event.preventDefault();
},
onInput(index, event) {
if (!event.target.value) {
event.preventDefault();
return;
}
this.autoFillText(index, event.target.value);
event.preventDefault();
for (let i = 0; i < codes.value.length; i++) {
if (codes.value[i].value) {
ret += codes.value[i].value;
} else {
break;
}
}
return ret;
});
function init(length: number, value: string): void {
codes.value.length = 0;
for (let i = 0; i < length; i++) {
const code: PinCode = {
value: '',
inputType: 'tel',
inputTimer: null,
focused: false
};
if (value && value[i]) {
code.value = value[i];
if (props.secure) {
code.inputType = 'password';
}
}
codes.value.push(code);
}
}
function autoFillText(index: number, text: string): void {
let lastIndex = index;
for (let i = index, j = 0; i < codes.value.length && j < text.length; i++, j++) {
if (text[j] < '0' || text[j] > '9') {
codes.value[i].value = '';
break;
}
codes.value[i].value = text[j];
setInputType(i);
lastIndex = i;
}
setFocus(lastIndex);
if (finalPinCode.value.length === length) {
emit('pincode:confirm', finalPinCode.value);
}
}
function setInputType(index: number): void {
if (!props.secure) {
return;
}
if (!codes.value[index].value) {
codes.value[index].inputType = 'tel';
return;
}
if (codes.value[index].inputTimer) {
return;
}
codes.value[index].inputTimer = setTimeout(() => {
if (codes.value[index].value) {
codes.value[index].inputType = 'password';
} else {
codes.value[index].inputType = 'tel';
}
codes.value[index].inputTimer = null;
}, 300);
}
function setFocus(index: number): void {
if (pinCodeInputs.value && pinCodeInputs.value[index]) {
pinCodeInputs.value[index].focus();
pinCodeInputs.value[index].select();
}
}
function setPreviousFocus(index: number): void {
if (index > 0) {
setFocus(index - 1);
}
}
function setNextFocus(index: number): void {
if (index < props.length - 1) {
setFocus(index + 1);
}
}
function onKeydown(index: number, event: KeyboardEvent): void {
if (event.altKey || (event.key.indexOf('F') === 0 && (event.key.length === 2 || event.key.length === 3))) {
return;
}
if (index <= 0 && (event.shiftKey && event.key === 'Tab')) {
return;
}
if (index >= props.length - 1 && (!event.shiftKey && event.key === 'Tab')) {
return;
}
if (event.key === 'Enter' && finalPinCode.value.length === props.length) {
emit('pincode:confirm', finalPinCode.value);
event.preventDefault();
return;
}
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
setPreviousFocus(index);
event.preventDefault();
return;
}
if (event.key === 'ArrowRight' || (!event.shiftKey && event.key === 'Tab')) {
setNextFocus(index);
event.preventDefault();
return;
}
if (event.key === 'Home') {
setFocus(0);
event.preventDefault();
return;
}
if (event.key === 'End') {
setFocus(props.length - 1);
event.preventDefault();
return;
}
if (((event.ctrlKey || event.metaKey) && event.key === 'v') || event.key === 'Paste') {
return;
}
if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Del') {
for (let i = index; i < codes.value.length; i++) {
codes.value[i].value = '';
setInputType(i);
}
if (event.code === 'Backspace') {
setPreviousFocus(index);
}
event.preventDefault();
return;
}
if (event.key.length === 1 && '0' <= event.key && event.key <= '9') {
codes.value[index].value = event.key;
setInputType(index);
setNextFocus(index);
if (props.autoConfirm && finalPinCode.value.length === props.length) {
emit('pincode:confirm', finalPinCode.value);
}
}
event.preventDefault();
}
function onPaste(index: number, event: ClipboardEvent): void {
if (!event.clipboardData) {
event.preventDefault();
return;
}
const text = event.clipboardData.getData('Text');
if (!text) {
event.preventDefault();
return;
}
autoFillText(index, text);
event.preventDefault();
}
function onInput(index: number, event: Event | { target: { value: string }, preventDefault: () => void }): void {
if (!event.target || !(event.target as { value: string }).value) {
event.preventDefault();
return;
}
autoFillText(index, (event.target as { value: string }).value);
event.preventDefault();
}
watch(() => props.length, newValue => {
init(newValue, props.modelValue);
});
watch(() => props.modelValue, newValue => {
if (newValue === finalPinCode.value) {
return;
}
init(props.length, newValue);
});
watch(codes, () => {
emit('update:modelValue', finalPinCode.value);
}, {
deep: true
});
init(props.length, props.modelValue);
</script>
<style>
+318 -256
View File
@@ -5,7 +5,7 @@
:label="label" :placeholder="placeholder"
:persistent-placeholder="!!persistentPlaceholder"
:rules="enableRules ? rules : []" v-model="currentValue" v-if="!hide"
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste">
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste" @click="onClick">
<template #prepend-inner v-if="currency && prependText">
<div>{{ prependText }}</div>
</template>
@@ -19,7 +19,7 @@
:label="label" :placeholder="placeholder"
:persistent-placeholder="!!persistentPlaceholder"
:rules="enableRules ? rules : []" v-model="currentValue" v-if="hide"
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste">
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste" @click="onClick">
<template #prepend-inner v-if="currency && prependText">
<div>{{ prependText }}</div>
</template>
@@ -29,301 +29,363 @@
</v-text-field>
</template>
<script>
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import transactionConstants from '@/consts/transaction.js';
import { removeAll } from '@/lib/common.js';
import logger from '@/lib/logger.js';
import { useI18n } from '@/locales/helpers.ts';
export default {
props: [
'class',
'color',
'density',
'currency',
'showCurrency',
'label',
'placeholder',
'persistentPlaceholder',
'disabled',
'readonly',
'hide',
'enableRules',
'modelValue'
],
emits: [
'update:modelValue'
],
data() {
const self = this;
const userStore = useUserStore();
import type { CurrencyPrependAndAppendText } from '@/core/currency.ts';
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts';
import { removeAll } from '@/lib/common.ts';
import type { ComponentDensity } from '@/lib/ui/desktop.ts';
import logger from '@/lib/logger.ts';
return {
currentValue: self.getFormattedValue(userStore, self.modelValue),
rules: [
(v) => {
if (v === '') {
return self.$t('Amount value is not number');
}
const props = defineProps<{
class?: string;
color?: string;
density?: ComponentDensity;
currency: string;
showCurrency?: boolean;
label?: string;
placeholder?: string;
persistentPlaceholder?: boolean;
disabled?: boolean;
readonly?: boolean;
hide?: boolean;
enableRules?: boolean;
flipNegative?: boolean;
modelValue: number;
}>();
try {
const val = self.$locale.parseAmount(userStore, v);
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
}>();
if (Number.isNaN(val) || !Number.isFinite(val)) {
return self.$t('Amount value is not number');
}
const {
tt,
getCurrentDecimalSeparator,
getCurrentDigitGroupingSymbol,
parseAmount,
formatAmount,
getAmountPrependAndAppendText
} = useI18n();
return (val >= transactionConstants.minAmountNumber && val <= transactionConstants.maxAmountNumber) || self.$t('Amount value exceeds limitation');
} catch (ex) {
logger.warn('cannot parse amount in amount input, original value is ' + v, ex);
return self.$t('Amount value is not number');
}
}
]
const rules = [
(v: string) => {
if (v === '') {
return tt('Amount value is not number');
}
},
computed: {
...mapStores(useSettingsStore, useUserStore),
extraClass() {
let finalClass = this.class;
if (this.color) {
finalClass += ` text-${this.color}`;
try {
const val = parseAmount(v);
if (Number.isNaN(val) || !Number.isFinite(val)) {
return tt('Amount value is not number');
}
if (this.currency && this.prependText) {
finalClass += ` has-pretend-text`;
}
return finalClass;
},
prependText() {
if (!this.currency || !this.showCurrency) {
return '';
}
const texts = this.getDisplayCurrencyPrependAndAppendText();
if (!texts) {
return '';
}
return texts.prependText;
},
appendText() {
if (!this.currency || !this.showCurrency) {
return '';
}
const texts = this.getDisplayCurrencyPrependAndAppendText();
if (!texts) {
return '';
}
return texts.appendText;
return (val >= TRANSACTION_MIN_AMOUNT && val <= TRANSACTION_MAX_AMOUNT) || tt('Amount value exceeds limitation');
} catch (ex) {
logger.warn('cannot parse amount in amount input, original value is ' + v, ex);
return tt('Amount value is not number');
}
},
watch: {
'currency': function () {
const newStringValue = this.getFormattedValue(this.userStore, this.modelValue);
}
];
if (!(newStringValue === '0' && this.currentValue === '')) {
this.currentValue = newStringValue;
}
},
'modelValue': function (newValue) {
const numericCurrentValue = this.$locale.parseAmount(this.userStore, this.currentValue);
const currentValue = ref<string>(getInitedFormattedValue(props.modelValue, props.flipNegative));
if (newValue !== numericCurrentValue) {
const newStringValue = this.getFormattedValue(this.userStore, newValue);
const prependText = computed<string | undefined>(() => {
if (!props.currency || !props.showCurrency) {
return '';
}
if (!(newStringValue === '0' && this.currentValue === '')) {
this.currentValue = newStringValue;
}
}
},
'currentValue': function (newValue) {
let finalValue = '';
const texts = getDisplayCurrencyPrependAndAppendText();
if (newValue) {
const decimalSeparator = this.$locale.getCurrentDecimalSeparator(this.userStore);
if (!texts) {
return '';
}
for (let i = 0; i < newValue.length; i++) {
if (!('0' <= newValue[i] && newValue[i] <= '9') && newValue[i] !== '-' && newValue[i] !== decimalSeparator) {
break;
}
return texts.prependText;
});
finalValue += newValue[i];
}
}
const appendText = computed<string | undefined>(() => {
if (!props.currency || !props.showCurrency) {
return '';
}
if (finalValue !== newValue) {
this.currentValue = finalValue;
} else {
this.$emit('update:modelValue', this.$locale.parseAmount(this.userStore, finalValue));
}
const texts = getDisplayCurrencyPrependAndAppendText();
if (!texts) {
return '';
}
return texts.appendText;
});
const extraClass = computed<string>(() => {
let finalClass = props.class || '';
if (props.color) {
finalClass += ` text-${props.color}`;
}
if (props.currency && prependText.value) {
finalClass += ` has-pretend-text`;
}
return finalClass;
});
function onKeyUpDown(e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey || (e.key.indexOf('F') === 0 && (e.key.length === 2 || e.key.length === 3))
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Home' || e.key === 'End' || e.key === 'Tab'
|| e.key === 'Backspace' || e.key === 'Delete' || e.key === 'Del') {
return;
}
if (props.readonly || props.disabled) {
e.preventDefault();
return;
}
const digitGroupingSymbol = getCurrentDigitGroupingSymbol();
const decimalSeparator = getCurrentDecimalSeparator();
if (!('0' <= e.key && e.key <= '9') && e.key !== '-' && e.key !== decimalSeparator) {
e.preventDefault();
return;
}
if (!e.target) {
return;
}
const target = e.target as HTMLInputElement;
let str = target.value;
if (str.indexOf(digitGroupingSymbol) >= 0) {
str = removeAll(str, digitGroupingSymbol);
}
if (e.key === '-' && str.lastIndexOf('-') > 0) {
const lastMinusPos = str.lastIndexOf('-');
target.value = str.substring(0, lastMinusPos) + str.substring(lastMinusPos + 1, str.length);
currentValue.value = target.value;
e.preventDefault();
return;
}
if (e.key === decimalSeparator && str.indexOf(decimalSeparator) !== str.lastIndexOf(decimalSeparator)) {
const lastDecimalSeparatorPos = str.lastIndexOf(decimalSeparator);
target.value = str.substring(0, lastDecimalSeparatorPos) + str.substring(lastDecimalSeparatorPos + 1, str.length);
currentValue.value = target.value;
e.preventDefault();
return;
}
if (e.key === decimalSeparator && (str.indexOf(decimalSeparator) === 0 || (str.indexOf(decimalSeparator) === 1 && str.charAt(0) === '-'))) {
const negative = str.charAt(0) === '-';
if (negative) {
str = str.substring(1);
}
},
methods: {
onKeyUpDown(e) {
if (e.altKey || e.ctrlKey || e.metaKey || (e.key.indexOf('F') === 0 && (e.key.length === 2 || e.key.length === 3))
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Home' || e.key === 'End' || e.key === 'Tab'
|| e.key === 'Backspace' || e.key === 'Delete' || e.key === 'Del') {
return;
}
const digitGroupingSymbol = this.$locale.getCurrentDigitGroupingSymbol(this.userStore);
const decimalSeparator = this.$locale.getCurrentDecimalSeparator(this.userStore);
str = (negative ? '-0' : '0') + str;
target.value = str;
currentValue.value = target.value;
e.preventDefault();
return;
}
if (!('0' <= e.key && e.key <= '9') && e.key !== '-' && e.key !== decimalSeparator) {
e.preventDefault();
return;
}
let decimalLength = 0;
const decimalIndex = str.indexOf(decimalSeparator);
let str = e.target.value;
if (decimalIndex >= 0) {
decimalLength = str.length - str.indexOf(decimalSeparator) - 1;
} else if ((str.startsWith('0') && str.length >= 2) || (str.startsWith('-0') && str.length >= 3)) {
const negative = str.charAt(0) === '-';
if (str.indexOf(digitGroupingSymbol) >= 0) {
str = removeAll(str, digitGroupingSymbol);
}
if (negative) {
str = str.substring(1);
}
if (e.key === '-' && str.lastIndexOf('-') > 0) {
const lastMinusPos = str.lastIndexOf('-');
e.target.value = str.substring(0, lastMinusPos) + str.substring(lastMinusPos + 1, str.length);
this.currentValue = e.target.value;
e.preventDefault();
return;
}
while (str.charAt(0) === '0' && (str.length >= 2 || e.key !== '0')) {
str = str.substring(1);
}
if (e.key === decimalSeparator && str.indexOf(decimalSeparator) !== str.lastIndexOf(decimalSeparator)) {
const lastDecimalSeparatorPos = str.lastIndexOf(decimalSeparator);
e.target.value = str.substring(0, lastDecimalSeparatorPos) + str.substring(lastDecimalSeparatorPos + 1, str.length);
this.currentValue = e.target.value;
e.preventDefault();
return;
}
target.value = (negative ? '-' : '') + str;
currentValue.value = target.value;
e.preventDefault();
return;
}
if (e.key === decimalSeparator && (str.indexOf(decimalSeparator) === 0 || (str.indexOf(decimalSeparator) === 1 && str.charAt(0) === '-'))) {
const negative = str.charAt(0) === '-';
if (decimalLength > 2) {
target.value = str.substring(0, Math.min(decimalIndex + 3, str.length - 1));
currentValue.value = target.value;
e.preventDefault();
return;
}
if (negative) {
str = str.substring(1);
}
try {
const val = parseAmount(str);
const finalValue = getValidFormattedValue(val, str, decimalIndex >= 0);
str = (negative ? '-0' : '0') + str;
e.target.value = str;
this.currentValue = e.target.value;
e.preventDefault();
return;
}
let decimalLength = 0;
let decimalIndex = str.indexOf(decimalSeparator);
if (decimalIndex >= 0) {
decimalLength = str.length - str.indexOf(decimalSeparator) - 1;
} else if ((str.startsWith('0') && str.length >= 2) || (str.startsWith('-0') && str.length >= 3)) {
const negative = str.charAt(0) === '-';
if (negative) {
str = str.substring(1);
}
while (str.charAt(0) === '0' && (str.length >= 2 || e.key !== '0')) {
str = str.substring(1);
}
e.target.value = (negative ? '-' : '') + str;
this.currentValue = e.target.value;
e.preventDefault();
return;
}
if (decimalLength > 2) {
e.target.value = str.substring(0, Math.min(decimalIndex + 3, str.length - 1));
this.currentValue = e.target.value;
e.preventDefault();
return;
}
try {
const val = this.$locale.parseAmount(this.userStore, str);
const finalValue = this.getValidFormattedValue(val, str, decimalIndex >= 0);
if (finalValue !== str) {
e.target.value = finalValue;
this.currentValue = finalValue;
e.preventDefault();
}
} catch (ex) {
ex.target.value = '0';
}
},
onPaste(e) {
if (!e.clipboardData) {
e.preventDefault();
return;
}
const text = e.clipboardData.getData('Text');
if (!text) {
e.preventDefault();
return;
}
const value = this.$locale.parseAmount(this.userStore, text);
const textualValue = this.getFormattedValue(this.userStore, value);
const decimalSeparator = this.$locale.getCurrentDecimalSeparator(this.userStore);
const hasDecimalSeparator = text.indexOf(decimalSeparator) >= 0;
this.currentValue = this.getValidFormattedValue(value, textualValue, hasDecimalSeparator);
if (finalValue !== str) {
target.value = finalValue;
currentValue.value = finalValue;
e.preventDefault();
},
getValidFormattedValue(value, textualValue, hasDecimalSeparator) {
let maxLength = transactionConstants.maxAmountNumber.toString().length;
}
} catch (ex) {
logger.warn('cannot parse amount in amount input, original value is ' + str, ex);
target.value = '0';
}
}
if (value < 0) {
maxLength = transactionConstants.minAmountNumber.toString().length;
}
function onPaste(e: ClipboardEvent): void {
if (!e.clipboardData || props.readonly || props.disabled) {
e.preventDefault();
return;
}
if (value < transactionConstants.minAmountNumber) {
return this.getFormattedValue(this.userStore, transactionConstants.minAmountNumber);
} else if (value > transactionConstants.maxAmountNumber) {
return this.getFormattedValue(this.userStore, transactionConstants.maxAmountNumber);
}
const text = e.clipboardData.getData('Text');
if (!hasDecimalSeparator && textualValue.length > maxLength) {
return textualValue.substring(0, maxLength);
} else if (hasDecimalSeparator && textualValue.length > maxLength + 1) {
return textualValue.substring(0, maxLength + 1);
}
if (!text) {
e.preventDefault();
return;
}
return textualValue;
},
getFormattedValue(userStore, value) {
if (!Number.isNaN(value) && Number.isFinite(value)) {
const digitGroupingSymbol = this.$locale.getCurrentDigitGroupingSymbol(userStore);
return removeAll(this.$locale.formatAmount(userStore, value, this.currency), digitGroupingSymbol);
}
const value = parseAmount(text);
const textualValue = getFormattedValue(value);
const decimalSeparator = getCurrentDecimalSeparator();
const hasDecimalSeparator = text.indexOf(decimalSeparator) >= 0;
return '0';
},
getDisplayCurrencyPrependAndAppendText() {
const numericCurrentValue = this.$locale.parseAmount(this.userStore, this.currentValue);
const isPlural = numericCurrentValue !== 100 && numericCurrentValue !== -100;
currentValue.value = getValidFormattedValue(value, textualValue, hasDecimalSeparator);
e.preventDefault();
}
return this.$locale.getAmountPrependAndAppendText(this.settingsStore, this.userStore, this.currency, isPlural);
function onClick(e: MouseEvent): void {
if (!props.disabled && !props.readonly && props.modelValue === 0 && e.target instanceof HTMLInputElement) {
const input = e.target as HTMLInputElement;
if ((!input?.selectionStart && !input?.selectionEnd) || input?.selectionStart === input?.selectionEnd) {
input?.select();
}
}
}
function getValidFormattedValue(value: number, textualValue: string, hasDecimalSeparator: boolean): string {
let maxLength = TRANSACTION_MAX_AMOUNT.toString().length;
if (value < 0) {
maxLength = TRANSACTION_MIN_AMOUNT.toString().length;
}
if (value < TRANSACTION_MIN_AMOUNT) {
return getFormattedValue(TRANSACTION_MIN_AMOUNT);
} else if (value > TRANSACTION_MAX_AMOUNT) {
return getFormattedValue(TRANSACTION_MAX_AMOUNT);
}
if (!hasDecimalSeparator && textualValue.length > maxLength) {
return textualValue.substring(0, maxLength);
} else if (hasDecimalSeparator && textualValue.length > maxLength + 1) {
return textualValue.substring(0, maxLength + 1);
}
return textualValue;
}
function getInitedFormattedValue(value: number, flipNegative?: boolean): string {
if (flipNegative) {
value = -value;
}
return getFormattedValue(value);
}
function getFormattedValue(value: number): string {
if (!Number.isNaN(value) && Number.isFinite(value)) {
const digitGroupingSymbol = getCurrentDigitGroupingSymbol();
return removeAll(formatAmount(value, props.currency), digitGroupingSymbol);
}
return '0';
}
function getDisplayCurrencyPrependAndAppendText(): CurrencyPrependAndAppendText | null {
const numericCurrentValue = parseAmount(currentValue.value);
const isPlural = numericCurrentValue !== 100 && numericCurrentValue !== -100;
return getAmountPrependAndAppendText(props.currency, isPlural);
}
watch(() => props.currency, () => {
const newStringValue = getInitedFormattedValue(props.modelValue, props.flipNegative);
if (!(newStringValue === '0' && currentValue.value === '')) {
currentValue.value = newStringValue;
}
});
watch(() => props.flipNegative, (newValue) => {
const newStringValue = getInitedFormattedValue(props.modelValue, newValue);
if (!(newStringValue === '0' && currentValue.value === '')) {
currentValue.value = newStringValue;
}
});
watch(() => props.modelValue, (newValue) => {
if (props.flipNegative) {
newValue = -newValue;
}
const numericCurrentValue = parseAmount(currentValue.value);
if (newValue !== numericCurrentValue) {
const newStringValue = getFormattedValue(newValue);
if (!(newStringValue === '0' && currentValue.value === '')) {
currentValue.value = newStringValue;
}
}
});
watch(currentValue, (newValue) => {
let finalValue = '';
if (newValue) {
const decimalSeparator = getCurrentDecimalSeparator();
for (let i = 0; i < newValue.length; i++) {
if (!('0' <= newValue[i] && newValue[i] <= '9') && newValue[i] !== '-' && newValue[i] !== decimalSeparator) {
break;
}
finalValue += newValue[i];
}
}
if (finalValue !== newValue) {
currentValue.value = finalValue;
} else {
let value: number = parseAmount(finalValue);
if (props.flipNegative) {
value = -value;
}
emit('update:modelValue', value);
}
});
</script>
<style>
.text-field-with-colored-label.has-pretend-text .v-field {
grid-template-columns: max-content minmax(0, 1fr) min-content min-content;
}
.text-field-with-colored-label.has-pretend-text .v-field__input {
padding-left: 0.5rem;
}
+28 -23
View File
@@ -10,31 +10,36 @@
</div>
</template>
<script>
export default {
props: [
'disabled',
'buttons',
'modelValue'
],
emits: [
'update:modelValue'
],
computed: {
value: {
get: function () {
return this.modelValue;
},
set: function (value) {
if (value === this.modelValue) {
return;
}
<script setup lang="ts">
import { computed } from 'vue';
this.$emit('update:modelValue', value);
}
}
}
interface Button {
name: string;
value: unknown;
}
const props = defineProps<{
modelValue: unknown;
buttons: Button[];
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: unknown): void;
}>();
const value = computed<unknown>({
get: () => {
return props.modelValue;
},
set: value => {
if (value === props.modelValue) {
return;
}
emit('update:modelValue', value);
}
});
</script>
<style>
+50 -63
View File
@@ -11,7 +11,7 @@
>
<template #selection="{ item }">
<v-label class="cursor-pointer" style="padding-top: 3px">
<v-icon size="28" :icon="icons.square" :color="getFinalColor(item.raw)"/>
<v-icon size="28" :icon="mdiSquareRounded" :color="getFinalColor(item.raw)"/>
</v-label>
</template>
@@ -23,12 +23,12 @@
<div class="text-center" :key="colorInfo.color" v-for="colorInfo in row">
<div class="cursor-pointer" @click="color = colorInfo.color">
<v-icon class="ma-2" size="28"
:icon="icons.square" :color="getFinalColor(colorInfo.color)"
:icon="mdiSquareRounded" :color="getFinalColor(colorInfo.color)"
v-if="!modelValue || modelValue !== colorInfo.color" />
<v-badge class="right-bottom-icon" color="primary"
location="bottom right" offset-x="8" offset-y="8" :icon="icons.checked"
location="bottom right" offset-x="8" offset-y="8" :icon="mdiCheck"
v-if="modelValue && modelValue === colorInfo.color">
<v-icon class="ma-2" size="28" :icon="icons.square" :color="getFinalColor(colorInfo.color)" />
<v-icon class="ma-2" size="28" :icon="mdiSquareRounded" :color="getFinalColor(colorInfo.color)" />
</v-badge>
</div>
</div>
@@ -38,74 +38,61 @@
</v-select>
</template>
<script>
import colorConstants from '@/consts/color.js';
import { arrayContainsFieldValue } from '@/lib/common.js';
import { getColorsInRows } from '@/lib/color.js';
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
<script setup lang="ts">
import { ref, computed, useTemplateRef, nextTick } from 'vue';
import type { ColorValue, ColorInfo } from '@/core/color.ts';
import { DEFAULT_ICON_COLOR } from '@/consts/color.ts';
import { arrayContainsFieldValue } from '@/lib/common.ts';
import { getColorsInRows } from '@/lib/color.ts';
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
import {
mdiSquareRounded,
mdiCheck
} from '@mdi/js';
export default {
props: [
'modelValue',
'disabled',
'label',
'columnCount',
'allColorInfos'
],
emits: [
'update:modelValue',
],
data() {
const self = this;
const props = defineProps<{
modelValue: ColorValue;
disabled?: boolean;
label?: string;
columnCount?: number;
allColorInfos: ColorValue[];
}>();
return {
itemPerRow: self.columnCount || 7,
icons: {
square: mdiSquareRounded,
checked: mdiCheck
}
}
},
computed: {
allColorRows() {
return getColorsInRows(this.allColorInfos, this.itemPerRow);
},
color: {
get: function () {
return this.modelValue;
},
set: function (value) {
this.$emit('update:modelValue', value);
}
}
},
methods: {
hasSelectedIcon(row) {
return arrayContainsFieldValue(row, 'id', this.modelValue);
},
getFinalColor(color) {
if (color && color !== colorConstants.defaultAccountColor) {
return '#' + color;
} else {
return 'var(--default-icon-color)';
}
},
onMenuStateChanged(state) {
const self = this;
const emit = defineEmits<{
(e: 'update:modelValue', value: ColorValue): void;
}>();
if (state) {
self.$nextTick(() => {
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, null, '.row-has-selected-item');
}
});
const dropdownMenu = useTemplateRef<HTMLElement>('dropdownMenu');
const itemPerRow = ref<number>(props.columnCount || 7);
const allColorRows = computed<ColorInfo[][]>(() => getColorsInRows(props.allColorInfos, itemPerRow.value));
const color = computed<ColorValue>({
get: () => props.modelValue,
set: (value: ColorValue) => emit('update:modelValue', value)
});
function hasSelectedIcon(row: ColorInfo[]): boolean {
return arrayContainsFieldValue(row, 'id', props.modelValue);
}
function getFinalColor(color: ColorValue): string {
if (color && color !== DEFAULT_ICON_COLOR) {
return '#' + color;
} else {
return 'var(--default-icon-color)';
}
}
function onMenuStateChanged(state: boolean): void {
if (state) {
nextTick(() => {
if (dropdownMenu.value && dropdownMenu.value.parentElement) {
scrollToSelectedItem(dropdownMenu.value.parentElement, null, '.row-has-selected-item');
}
}
});
}
}
</script>
+81 -69
View File
@@ -7,84 +7,96 @@
<v-card-text v-if="textContent" class="pa-4 pb-6">{{ textContent }}</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn color="gray" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :color="finalColor" @click="confirm">{{ $t('OK') }}</v-btn>
<v-btn color="gray" @click="cancel">{{ tt('Cancel') }}</v-btn>
<v-btn :color="finalColor" @click="confirm">{{ tt('OK') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { isString } from '@/lib/common.js';
<script setup lang="ts">
import { ref, watch } from 'vue';
export default {
props: [
'show',
'color',
'title',
'text'
],
emits: [
'update:show'
],
expose: [
'open'
],
data() {
const self = this;
import { useI18n } from '@/locales/helpers.ts';
return {
showState: self.show,
titleContent: self.title || self.$t('global.app.title'),
textContent: self.text || '',
finalColor: self.color || 'primary',
resolve: null,
reject: null
import { isString, isObject } from '@/lib/common.ts';
const props = defineProps<{
show?: boolean;
color?: string;
title?: string;
text?: string;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
}>();
const { tt } = useI18n();
const showState = ref<boolean>(false);
const titleContent = ref<string>(props.title || tt('global.app.title'));
const textContent = ref<string>(props.text || '');
const finalColor = ref<string>(props.color || 'primary');
let resolveFunc: ((value?: unknown) => void) | null = null;
let rejectFunc: ((reason?: unknown) => void) | null = null;
function open(titleOrText: string, textOrOptions?: string | Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown> {
showState.value = true;
if (!textOrOptions || isObject(textOrOptions)) { // only one parameter or second parameter is options
titleContent.value = tt('global.app.title');
if (!textOrOptions) {
textContent.value = tt(titleOrText);
} else {
const actualOptions = textOrOptions as Record<string, unknown>;
textContent.value = tt(titleOrText, actualOptions);
}
},
watch: {
'showState': function (newValue) {
this.$emit('update:show', newValue);
}
},
methods: {
open(title, text, options) {
this.showState = true;
if (isString(text)) {
this.titleContent = this.$t(title, options);
this.textContent = this.$t(text, options);
} else {
options = text;
this.titleContent = this.$t('global.app.title');
this.textContent = this.$t(title, options);
}
if (options && options.color) {
this.finalColor = options.color || 'primary';
}
return new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
},
confirm() {
if (this.resolve) {
this.resolve();
}
this.showState = false;
this.$emit('update:show', false);
},
cancel() {
if (this.reject) {
this.reject();
}
this.showState = false;
this.$emit('update:show', false);
} else if (isString(textOrOptions)) { // second parameter is text
if (!options) {
titleContent.value = tt(titleOrText);
textContent.value = tt(textOrOptions);
} else {
titleContent.value = tt(titleOrText, options);
textContent.value = tt(textOrOptions, options);
}
}
if (options && isString(options['color'])) {
finalColor.value = (options['color'] as string) || 'primary';
}
return new Promise((resolve, reject) => {
resolveFunc = resolve;
rejectFunc = reject;
});
}
function confirm(): void {
if (resolveFunc) {
resolveFunc();
}
showState.value = false;
emit('update:show', false);
}
function cancel(): void {
if (rejectFunc) {
rejectFunc();
}
showState.value = false;
emit('update:show', false);
}
watch(showState, newValue => {
emit('update:show', newValue);
});
defineExpose({
open
});
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<v-autocomplete
item-title="displayName"
item-value="currencyCode"
auto-select-first
persistent-placeholder
:disabled="disabled"
:label="label"
:placeholder="placeholder"
:items="allCurrencies"
:no-data-text="tt('No results')"
:custom-filter="filterCurrency"
v-model="currentCurrencyValue"
>
<template #append-inner>
<small class="text-field-append-text smaller">{{ currentCurrencyValue }}</small>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<span>{{ item.title }}</span>
<v-spacer style="min-width: 40px" />
<v-icon :icon="mdiCheck" v-if="currentCurrencyValue === item.raw.currencyCode" />
<small class="text-field-append-text" v-if="currentCurrencyValue !== item.raw.currencyCode">{{ item.raw.currencyCode }}</small>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-autocomplete>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import type { LocalizedCurrencyInfo } from '@/core/currency.ts';
import {
mdiCheck
} from '@mdi/js';
const props = defineProps<{
disabled?: boolean;
label?: string;
placeholder?: string;
modelValue: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const { tt, getAllCurrencies } = useI18n();
const allCurrencies = computed<LocalizedCurrencyInfo[]>(() => getAllCurrencies());
const currentCurrencyValue = computed<string | null>({
get: () => props.modelValue,
set: (value: string | null) => {
if (value === null) {
emit('update:modelValue', '');
} else {
emit('update:modelValue', value);
}
}
});
function filterCurrency(value: string, query: string, item?: { value: unknown, raw: LocalizedCurrencyInfo }): boolean {
if (!item) {
return false;
}
const lowerCaseFilterContent = query.toLowerCase() || '';
if (!lowerCaseFilterContent) {
return true;
}
return item.raw.displayName.toLowerCase().indexOf(lowerCaseFilterContent) >= 0
|| item.raw.currencyCode.toLowerCase().indexOf(lowerCaseFilterContent) >= 0;
}
</script>
@@ -19,7 +19,6 @@
</template>
<v-card-text class="mb-md-4 w-100 d-flex justify-center">
<vue-date-picker inline enable-seconds auto-apply
ref="datetimepicker"
month-name-format="long"
six-weeks="center"
:clearable="false"
@@ -39,185 +38,94 @@
{{ getMonthShortName(text) }}
</template>
<template #am-pm-button="{ toggle, value }">
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ $t(`datetime.${value}.content`) }}</button>
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ tt(`datetime.${value}.content`) }}</button>
</template>
</vue-date-picker>
</v-card-text>
<v-card-text class="overflow-y-visible">
<div class="w-100 d-flex justify-center gap-4">
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ $t('OK') }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ tt('OK') }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useUserStore } from '@/stores/user.js';
import { useI18n } from '@/locales/helpers.ts';
import { type CommonDateRangeSelectionProps, useDateRangeSelectionBase } from '@/components/base/DateRangeSelectionBase.ts';
import { useUserStore } from '@/stores/user.ts';
import { ThemeType } from '@/core/theme.ts';
import datetimeConstants from '@/consts/datetime.js';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.js';
import {
getCurrentUnixTime,
getCurrentYear,
getUnixTime,
getLocalDatetimeFromUnixTime,
getTodayFirstUnixTime,
getDummyUnixTimeForLocalUsage,
getActualUnixTimeForStore,
getTimezoneOffsetMinutes,
getBrowserTimezoneOffsetMinutes,
getDateRangeByDateType
} from '@/lib/datetime.js';
getBrowserTimezoneOffsetMinutes
} from '@/lib/datetime.ts';
export default {
props: [
'minTime',
'maxTime',
'title',
'hint',
'persistent',
'show'
],
emits: [
'update:show',
'dateRange:change'
],
data() {
const self = this;
let minDate = getTodayFirstUnixTime();
let maxDate = getCurrentUnixTime();
interface DesktopDateRangeSelectionProps extends CommonDateRangeSelectionProps {
persistent?: boolean;
}
if (self.minTime) {
minDate = self.minTime;
const props = defineProps<DesktopDateRangeSelectionProps>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'dateRange:change', minUnixTime: number, maxUnixTime: number): void;
(e: 'error', message: string): void;
}>();
const theme = useTheme();
const { tt, getMonthShortName } = useI18n();
const { yearRange, dateRange, dayNames, isYearFirst, is24Hour, beginDateTime, endDateTime, presetRanges, getFinalDateRange } = useDateRangeSelectionBase(props);
const userStore = useUserStore();
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
const showState = computed<boolean>({
get: () => props.show || false,
set: (value) => emit('update:show', value)
});
function confirm(): void {
try {
const finalDateRange = getFinalDateRange();
if (!finalDateRange) {
return;
}
if (self.maxTime) {
maxDate = self.maxTime;
}
return {
yearRange: [
2000,
getCurrentYear() + 1
],
dateRange: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(minDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(maxDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
}
},
computed: {
...mapStores(useUserStore),
showState: {
get: function () {
return this.show;
},
set: function (value) {
this.$emit('update:show', value);
}
},
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
dayNames() {
return arrangeArrayWithNewStartIndex(this.$locale.getAllMinWeekdayNames(), this.firstDayOfWeek);
},
isYearFirst() {
return this.$locale.isLongDateMonthAfterYear(this.userStore);
},
is24Hour() {
return this.$locale.isLongTime24HourFormat(this.userStore);
},
beginDateTime() {
const actualBeginUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[0]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualBeginUnixTime);
},
endDateTime() {
const actualEndUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[1]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualEndUnixTime);
},
presetRanges() {
const presetRanges = [];
[
datetimeConstants.allDateRanges.Today,
datetimeConstants.allDateRanges.LastSevenDays,
datetimeConstants.allDateRanges.LastThirtyDays,
datetimeConstants.allDateRanges.ThisWeek,
datetimeConstants.allDateRanges.ThisMonth,
datetimeConstants.allDateRanges.ThisYear
].forEach(dateRangeType => {
const dateRange = getDateRangeByDateType(dateRangeType.type, this.firstDayOfWeek);
presetRanges.push({
label: this.$t(dateRangeType.name),
value: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.minTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.maxTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
});
});
return presetRanges;
}
},
watch: {
'minTime': function (newValue) {
if (newValue) {
this.dateRange[0] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
}
},
'maxTime': function (newValue) {
if (newValue) {
this.dateRange[1] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
}
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
confirm() {
if (!this.dateRange[0] || !this.dateRange[1]) {
return;
}
const currentMinDate = this.dateRange[0];
const currentMaxDate = this.dateRange[1];
let minUnixTime = getUnixTime(currentMinDate);
let maxUnixTime = getUnixTime(currentMaxDate);
if (minUnixTime < 0 || maxUnixTime < 0) {
this.$toast('Date is too early');
return;
}
minUnixTime = getActualUnixTimeForStore(minUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
maxUnixTime = getActualUnixTimeForStore(maxUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
this.$emit('dateRange:change', minUnixTime, maxUnixTime);
},
cancel() {
this.$emit('update:show', false);
},
getMonthShortName(month) {
return this.$locale.getMonthShortName(month);
emit('dateRange:change', finalDateRange.minUnixTime, finalDateRange.maxUnixTime);
} catch (ex: unknown) {
if (ex instanceof Error) {
emit('error', ex.message);
}
}
}
function cancel(): void {
emit('update:show', false);
}
watch(() => props.minTime, (newValue) => {
if (newValue) {
dateRange.value[0] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
}
});
watch(() => props.maxTime, (newValue) => {
if (newValue) {
dateRange.value[1] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
}
});
</script>
<style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<v-select
persistent-placeholder
:readonly="readonly"
:disabled="disabled"
:clearable="modelValue ? clearable : false"
:label="label"
:menu-props="{ 'content-class': 'date-select-menu' }"
v-model="dateTime"
>
<template #selection>
<span class="text-truncate cursor-pointer">{{ displayTime }}</span>
</template>
<template #no-data>
<vue-date-picker inline vertical auto-apply
ref="datepicker"
month-name-format="long"
model-type="yyyy-MM-dd"
:clearable="true"
:enable-time-picker="false"
:dark="isDarkMode"
:week-start="firstDayOfWeek"
:year-range="yearRange"
:day-names="dayNames"
:year-first="isYearFirst"
v-model="dateTime">
<template #month="{ text }">
{{ getMonthShortName(text) }}
</template>
<template #month-overlay-value="{ text }">
{{ getMonthShortName(text) }}
</template>
</vue-date-picker>
</template>
</v-select>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useTheme } from 'vuetify';
import { useI18n } from '@/locales/helpers.ts';
import { useUserStore } from '@/stores/user.ts';
import { ThemeType } from '@/core/theme.ts';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.ts';
import { getCurrentYear } from '@/lib/datetime.ts';
const props = defineProps<{
modelValue?: string;
disabled?: boolean;
readonly?: boolean;
clearable?: boolean;
label?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const theme = useTheme();
const { tt, getAllMinWeekdayNames, getMonthShortName, formatDateToLongDate, isLongDateMonthAfterYear } = useI18n();
const userStore = useUserStore();
const yearRange = ref<number[]>([
2000,
getCurrentYear() + 1
]);
const dateTime = computed<string>({
get: () => props.modelValue ?? '',
set: (value: string) => emit('update:modelValue', value)
});
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
const dayNames = computed<string[]>(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value));
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
const displayTime = computed<string>(() => {
if (props.modelValue) {
return formatDateToLongDate(props.modelValue);
} else {
return tt('Unspecified');
}
});
</script>
<style>
.date-select-menu {
max-height: inherit !important;
}
.date-select-menu .dp__menu {
border: 0;
}
</style>
+48 -69
View File
@@ -30,20 +30,23 @@
{{ getMonthShortName(text) }}
</template>
<template #am-pm-button="{ toggle, value }">
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ $t(`datetime.${value}.content`) }}</button>
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ tt(`datetime.${value}.content`) }}</button>
</template>
</vue-date-picker>
</template>
</v-select>
</template>
<script>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useUserStore } from '@/stores/user.js';
import { useI18n } from '@/locales/helpers.ts';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.js';
import { useUserStore } from '@/stores/user.ts';
import { ThemeType } from '@/core/theme.ts';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.ts';
import {
getCurrentYear,
getTimezoneOffsetMinutes,
@@ -51,76 +54,52 @@ import {
getLocalDatetimeFromUnixTime,
getActualUnixTimeForStore,
getUnixTime
} from '@/lib/datetime.js';
} from '@/lib/datetime.ts';
export default {
props: [
'modelValue',
'disabled',
'readonly',
'label'
],
emits: [
'update:modelValue',
'error'
],
data() {
return {
yearRange: [
2000,
getCurrentYear() + 1
]
}
const props = defineProps<{
modelValue: number;
disabled?: boolean;
readonly?: boolean;
label?: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
(e: 'error', message: string): void;
}>();
const theme = useTheme();
const { tt, getAllMinWeekdayNames, getMonthShortName, formatUnixTimeToLongDateTime, isLongDateMonthAfterYear, isLongTime24HourFormat } = useI18n();
const userStore = useUserStore();
const yearRange = ref<number[]>([
2000,
getCurrentYear() + 1
]);
const dateTime = computed<Date>({
get: () => {
return getLocalDatetimeFromUnixTime(props.modelValue);
},
computed: {
...mapStores(useUserStore),
dateTime: {
get: function () {
return getLocalDatetimeFromUnixTime(this.modelValue);
},
set: function (value) {
const unixTime = getUnixTime(value);
set: (value: Date) => {
const unixTime = getUnixTime(value);
if (unixTime < 0) {
this.$emit('error', 'Date is too early');
return;
}
this.$emit('update:modelValue', unixTime);
}
},
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
dayNames() {
return arrangeArrayWithNewStartIndex(this.$locale.getAllMinWeekdayNames(), this.firstDayOfWeek);
},
isYearFirst() {
return this.$locale.isLongDateMonthAfterYear(this.userStore);
},
is24Hour() {
return this.$locale.isLongTime24HourFormat(this.userStore);
},
displayTime() {
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, getActualUnixTimeForStore(this.modelValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
if (unixTime < 0) {
emit('error', 'Date is too early');
return;
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
getMonthShortName(month) {
return this.$locale.getMonthShortName(month);
}
emit('update:modelValue', unixTime);
}
}
});
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
const dayNames = computed<string[]>(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value));
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
const is24Hour = computed<boolean>(() => isLongTime24HourFormat());
const displayTime = computed<string>(() => formatUnixTimeToLongDateTime(getActualUnixTimeForStore(getUnixTime(dateTime.value), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())));
</script>
<style>
+41 -53
View File
@@ -24,7 +24,7 @@
<div class="cursor-pointer" @click="icon = iconInfo.id">
<ItemIcon class="ma-2" icon-type="fixed" :icon-id="iconInfo.icon" :color="color" v-if="!modelValue || modelValue !== iconInfo.id" />
<v-badge class="right-bottom-icon" color="primary"
location="bottom right" offset-x="8" offset-y="10" :icon="icons.checked"
location="bottom right" offset-x="8" offset-y="10" :icon="mdiCheck"
v-if="modelValue && modelValue === iconInfo.id">
<ItemIcon class="ma-2" icon-type="fixed" :icon-id="iconInfo.icon" :color="color" />
</v-badge>
@@ -36,66 +36,54 @@
</v-select>
</template>
<script>
import { arrayContainsFieldValue } from '@/lib/common.js';
import { getIconsInRows } from '@/lib/icon.js';
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
<script setup lang="ts">
import { ref, computed, useTemplateRef, nextTick } from 'vue';
import type { ColorValue } from '@/core/color.ts';
import type { IconInfo, IconInfoWithId } from '@/core/icon.ts';
import { arrayContainsFieldValue } from '@/lib/common.ts';
import { getIconsInRows } from '@/lib/icon.ts';
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
import {
mdiCheck
} from '@mdi/js';
export default {
props: [
'modelValue',
'disabled',
'label',
'iconType',
'color',
'columnCount',
'allIconInfos'
],
emits: [
'update:modelValue',
],
data() {
const self = this;
const props = defineProps<{
modelValue: string;
disabled?: boolean;
label?: string;
iconType: string;
color: ColorValue;
columnCount?: number;
allIconInfos: Record<string, IconInfo>;
}>();
return {
itemPerRow: self.columnCount || 7,
icons: {
checked: mdiCheck
}
}
},
computed: {
allIconRows() {
return getIconsInRows(this.allIconInfos, this.itemPerRow);
},
icon: {
get: function () {
return this.modelValue;
},
set: function (value) {
this.$emit('update:modelValue', value);
}
}
},
methods: {
hasSelectedIcon(row) {
return arrayContainsFieldValue(row, 'id', this.modelValue);
},
onMenuStateChanged(state) {
const self = this;
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
if (state) {
self.$nextTick(() => {
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, null, '.row-has-selected-item');
}
});
const dropdownMenu = useTemplateRef<HTMLElement>('dropdownMenu');
const itemPerRow = ref<number>(props.columnCount || 7);
const allIconRows = computed<IconInfoWithId[][]>(() => getIconsInRows(props.allIconInfos, itemPerRow.value));
const icon = computed<string>({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
});
function hasSelectedIcon(row: IconInfoWithId[]): boolean {
return arrayContainsFieldValue(row, 'id', props.modelValue);
}
function onMenuStateChanged(state: boolean): void {
if (state) {
nextTick(() => {
if (dropdownMenu.value && dropdownMenu.value.parentElement) {
scrollToSelectedItem(dropdownMenu.value.parentElement, null, '.row-has-selected-item');
}
}
});
}
}
</script>
+24 -140
View File
@@ -3,7 +3,7 @@
<slot></slot>
</i>
<v-badge class="right-bottom-icon" color="secondary"
location="bottom right" offset-y="4" :icon="icons.hide"
location="bottom right" offset-y="4" :icon="mdiEyeOffOutline"
v-if="hiddenStatus">
<i class="item-icon" :class="classes" :style="style">
<slot></slot>
@@ -11,151 +11,35 @@
</v-badge>
</template>
<script>
import iconConstants from '@/consts/icon.js';
import colorConstants from '@/consts/color.js';
import { isNumber } from '@/lib/common.js';
<script setup lang="ts">
import { computed } from 'vue';
import { type CommonIconProps, useItemIconBase } from '@/components/base/ItemIconBase.ts';
import {
mdiEyeOffOutline
} from '@mdi/js';
export default {
props: [
'class',
'iconType',
'iconId',
'color',
'defaultColor',
'additionalColorAttr',
'size',
'hiddenStatus'
],
data() {
return {
icons: {
hide: mdiEyeOffOutline
}
}
},
computed: {
classes() {
let allClasses = this.class ? (this.class + ' ') : '';
if (this.iconType === 'account') {
allClasses += this.getAccountIcon(this.iconId);
} else if (this.iconType === 'category') {
allClasses += this.getCategoryIcon(this.iconId);
} else if (this.iconType === 'fixed') {
allClasses += this.iconId;
}
return allClasses;
},
style() {
let defaultColor = 'var(--default-icon-color)';
if (this.defaultColor) {
defaultColor = this.defaultColor;
}
if (this.iconType === 'account') {
return this.getAccountIconStyle(this.color, defaultColor, this.additionalColorAttr);
} else if (this.iconType === 'category') {
return this.getCategoryIconStyle(this.color, defaultColor, this.additionalColorAttr);
} else {
return this.getDefaultIconStyle(this.color, defaultColor, this.additionalColorAttr);
}
}
},
methods: {
getAccountIcon(iconId) {
if (isNumber(iconId)) {
iconId = iconId.toString();
}
if (!iconConstants.allAccountIcons[iconId]) {
return iconConstants.defaultAccountIcon.icon;
}
return iconConstants.allAccountIcons[iconId].icon;
},
getCategoryIcon(iconId) {
if (isNumber(iconId)) {
iconId = iconId.toString();
}
if (!iconConstants.allCategoryIcons[iconId]) {
return iconConstants.defaultCategoryIcon.icon;
}
return iconConstants.allCategoryIcons[iconId].icon;
},
getAccountIconStyle(color, defaultColor, additionalColorAttr) {
if (color && color !== colorConstants.defaultAccountColor) {
color = '#' + color;
} else {
color = defaultColor;
}
const ret = {
color: color
};
if (additionalColorAttr) {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
},
getCategoryIconStyle(color, defaultColor, additionalColorAttr) {
if (color && color !== colorConstants.defaultCategoryColor) {
color = '#' + color;
} else {
color = defaultColor;
}
const ret = {
color: color
};
if (additionalColorAttr) {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
},
getDefaultIconStyle(color, defaultColor, additionalColorAttr) {
if (color && color !== colorConstants.defaultColor) {
color = '#' + color;
} else {
color = defaultColor;
}
const ret = {
color: color
};
if (additionalColorAttr) {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
}
}
interface DesktopItemIconProps extends CommonIconProps {
class?: string;
hiddenStatus?: boolean;
}
const props = defineProps<DesktopItemIconProps>();
const { style, getAccountIcon, getCategoryIcon } = useItemIconBase(props);
const classes = computed<string>(() => {
let allClasses = props.class ? (props.class + ' ') : '';
if (props.iconType === 'account') {
allClasses += getAccountIcon(props.iconId);
} else if (props.iconType === 'category') {
allClasses += getCategoryIcon(props.iconId);
} else if (props.iconType === 'fixed') {
allClasses += props.iconId;
}
return allClasses;
});
</script>
<style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<v-select
item-title="nativeDisplayName"
item-value="languageTag"
persistent-placeholder
:disabled="disabled"
:label="label"
:placeholder="placeholder"
:items="allLanguages"
v-model="currentLocaleValue"
>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<span>{{ item.title }}</span>
<v-spacer style="min-width: 40px" />
<v-icon :icon="mdiCheck" v-if="isLanguageSelected(item.raw.languageTag)" />
<span class="text-field-append-text" v-if="!isLanguageSelected(item.raw.languageTag)">{{ item.raw.displayName }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-select>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { type LanguageSelectBaseProps, type LanguageSelectBaseEmits, useLanguageSelectButtonBase } from '@/components/base/LanguageSelectBase.ts';
import { useI18n } from '@/locales/helpers.ts';
import {
mdiCheck
} from '@mdi/js';
interface DesktopLanguageSelectProps extends LanguageSelectBaseProps {
label?: string;
placeholder?: string;
}
const props = defineProps<DesktopLanguageSelectProps>();
const emit = defineEmits<LanguageSelectBaseEmits>();
const { getCurrentLanguageTag } = useI18n();
const {
allLanguages,
updateLanguage,
isLanguageSelected
} = useLanguageSelectButtonBase(props, emit);
const currentLocaleValue = computed<string>({
get: () => {
if (props.useModelValue) {
return props.modelValue ?? '';
} else {
return getCurrentLanguageTag()
}
},
set: (value: string) => {
updateLanguage(value);
}
});
</script>
@@ -0,0 +1,37 @@
<template>
<v-menu location="bottom" max-height="500">
<template #activator="{ props }">
<v-btn variant="text" :disabled="disabled" v-bind="props">{{ currentLanguageName }}</v-btn>
</template>
<v-list>
<v-list-item :key="lang.languageTag" :value="lang.languageTag" v-for="lang in allLanguages">
<v-list-item-title class="cursor-pointer" @click="updateLanguage(lang.languageTag)">
<div class="d-flex align-center">
<span>{{ lang.nativeDisplayName }}</span>
<v-spacer style="min-width: 40px" />
<v-icon :icon="mdiCheck" v-if="isLanguageSelected(lang.languageTag)" />
<span class="text-field-append-text" v-if="!isLanguageSelected(lang.languageTag)">{{ lang.displayName }}</span>
</div>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<script setup lang="ts">
import { type LanguageSelectBaseProps, type LanguageSelectBaseEmits, useLanguageSelectButtonBase } from '@/components/base/LanguageSelectBase.ts';
import {
mdiCheck
} from '@mdi/js';
const props = defineProps<LanguageSelectBaseProps>();
const emit = defineEmits<LanguageSelectBaseEmits>();
const {
allLanguages,
currentLanguageName,
updateLanguage,
isLanguageSelected
} = useLanguageSelectButtonBase(props, emit);
</script>
@@ -26,7 +26,7 @@
:dark="isDarkMode"
:year-range="yearRange"
:year-first="isYearFirst"
v-model="startTime">
v-model="dateRange[0]">
<template #month="{ text }">
{{ getMonthShortName(text) }}
</template>
@@ -42,7 +42,7 @@
:dark="isDarkMode"
:year-range="yearRange"
:year-first="isYearFirst"
v-model="endTime">
v-model="dateRange[1]">
<template #month="{ text }">
{{ getMonthShortName(text) }}
</template>
@@ -55,131 +55,85 @@
</v-card-text>
<v-card-text class="overflow-y-visible">
<div class="w-100 d-flex justify-center gap-4">
<v-btn :disabled="!startTime || !endTime" @click="confirm">{{ $t('OK') }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ tt('OK') }}</v-btn>
<v-btn color="secondary" variant="tonal" @click="cancel">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useUserStore } from '@/stores/user.js';
import { useI18n } from '@/locales/helpers.ts';
import { type CommonMonthRangeSelectionProps, useMonthRangeSelectionBase } from '@/components/base/MonthRangeSelectionBase.ts';
import {
getYearMonthObjectFromString,
getYearMonthStringFromObject,
getCurrentUnixTime,
getCurrentYear,
getThisYearFirstUnixTime,
getYearMonthFirstUnixTime,
getYearMonthLastUnixTime
} from '@/lib/datetime.js';
import { ThemeType } from '@/core/theme.ts';
import { getYearMonthObjectFromString } from '@/lib/datetime.ts';
export default {
props: [
'minTime',
'maxTime',
'title',
'hint',
'persistent',
'show'
],
emits: [
'update:show',
'dateRange:change'
],
data() {
const self = this;
let minDate = getThisYearFirstUnixTime();
let maxDate = getCurrentUnixTime();
interface DesktopMonthRangeSelectionProps extends CommonMonthRangeSelectionProps {
persistent?: boolean;
}
if (self.minTime) {
minDate = getYearMonthObjectFromString(self.minTime);
const props = defineProps<DesktopMonthRangeSelectionProps>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'dateRange:change', minYearMonth: string, maxYearMonth: string): void;
(e: 'error', message: string): void;
}>();
const theme = useTheme();
const { tt, getMonthShortName } = useI18n();
const { yearRange, dateRange, isYearFirst, beginDateTime, endDateTime, getFinalMonthRange } = useMonthRangeSelectionBase(props);
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const showState = computed<boolean>({
get: () => props.show || false,
set: (value) => emit('update:show', value)
});
function confirm(): void {
try {
const finalMonthRange = getFinalMonthRange();
if (!finalMonthRange) {
return;
}
if (self.maxTime) {
maxDate = getYearMonthObjectFromString(self.maxTime);
}
return {
yearRange: [
2000,
getCurrentYear() + 1
],
startTime: minDate,
endTime: maxDate
}
},
computed: {
...mapStores(useUserStore),
showState: {
get: function () {
return this.show;
},
set: function (value) {
this.$emit('update:show', value);
}
},
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
isYearFirst() {
return this.$locale.isLongDateMonthAfterYear(this.userStore);
},
beginDateTime() {
return this.$locale.formatUnixTimeToLongYearMonth(this.userStore, getYearMonthFirstUnixTime(this.startTime));
},
endDateTime() {
return this.$locale.formatUnixTimeToLongYearMonth(this.userStore, getYearMonthLastUnixTime(this.endTime));
}
},
watch: {
'minTime': function (newValue) {
if (newValue) {
this.startTime = getYearMonthObjectFromString(newValue);
}
},
'maxTime': function (newValue) {
if (newValue) {
this.endTime = getYearMonthObjectFromString(newValue);
}
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
confirm() {
if (!this.startTime || !this.endTime) {
return;
}
if (this.startTime.year <= 0 || this.startTime.month < 0 || this.endTime.year <= 0 || this.endTime.month < 0) {
this.$toast('Date is too early');
return;
}
const minYearMonth = getYearMonthStringFromObject(this.startTime);
const maxYearMonth = getYearMonthStringFromObject(this.endTime);
this.$emit('dateRange:change', minYearMonth, maxYearMonth);
},
cancel() {
this.$emit('update:show', false);
},
getMonthShortName(month) {
return this.$locale.getMonthShortName(month);
emit('dateRange:change', finalMonthRange.minYearMonth, finalMonthRange.maxYearMonth);
} catch (ex: unknown) {
if (ex instanceof Error) {
emit('error', ex.message);
}
}
}
function cancel(): void {
emit('update:show', false);
}
watch(() => props.minTime, (newValue) => {
if (newValue) {
const yearMonth = getYearMonthObjectFromString(newValue);
if (yearMonth) {
dateRange.value[0] = yearMonth;
}
}
});
watch(() => props.maxTime, (newValue) => {
if (newValue) {
const yearMonth = getYearMonthObjectFromString(newValue);
if (yearMonth) {
dateRange.value[1] = yearMonth;
}
}
});
</script>
<style>
@@ -0,0 +1,101 @@
<template>
<v-pagination :density="density"
:disabled="disabled"
:total-visible="totalVisible ?? 7"
:length="totalPageCount"
v-model="currentPage">
<template #item="{ key, page, isActive }">
<v-btn variant="text"
:density="density"
:disabled="disabled"
:icon="true"
:color="isActive ? 'primary' : 'default'"
@click="currentPage = parseInt(page)"
v-if="page !== '...'"
>
<span>{{ page }}</span>
</v-btn>
<v-btn variant="text"
color="default"
:density="density"
:disabled="disabled"
:icon="true"
v-if="page === '...'"
>
<span>{{ page }}</span>
<v-menu activator="parent"
:disabled="disabled"
:close-on-content-click="false"
v-model="showMenus[key]">
<v-list>
<v-list-item class="text-sm" :density="density">
<v-list-item-title class="cursor-pointer">
<v-autocomplete width="100"
item-title="page"
item-value="page"
auto-select-first="exact"
:density="density"
:items="allPages"
:no-data-text="tt('No results')"
v-model="currentPage"/>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
</v-pagination>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import type { ComponentDensity } from '@/lib/ui/desktop.ts';
const props = defineProps<{
density?: ComponentDensity;
disabled?: boolean;
totalPageCount: number;
totalVisible?: number;
modelValue: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
}>();
const { tt } = useI18n();
const showMenus = ref<Record<string, boolean>>({});
const allPages = computed<{ page: number }[]>(() => {
const pages = [];
for (let i = 1; i <= props.totalPageCount; i++) {
pages.push({
page: i
});
}
return pages;
});
const currentPage = computed<number>({
get: () => props.modelValue,
set: (value) => {
if (value && value >= 1 && value <= props.totalPageCount) {
emit('update:modelValue', value);
for (const key in showMenus.value) {
if (!Object.prototype.hasOwnProperty.call(showMenus.value, key)) {
continue;
}
showMenus.value[key] = false;
}
}
}
});
</script>
+233 -269
View File
@@ -3,295 +3,259 @@
@click="clickItem" @legendselectchanged="onLegendSelectChanged" />
</template>
<script>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import { useUserStore } from '@/stores/user.js';
import type { ECElementEvent } from 'echarts/core';
import type { CallbackDataParams } from 'echarts/types/dist/shared';
import colorConstants from '@/consts/color.js';
import { formatPercent } from '@/lib/numeral.js';
import { useI18n } from '@/locales/helpers.ts';
import { type CommonPieChartDataItem, type CommonPieChartProps, usePieChartBase } from '@/components/base/PieChartBase.ts'
export default {
props: [
'skeleton',
'items',
'idField',
'nameField',
'valueField',
'percentField',
'colorField',
'hiddenField',
'minValidPercent',
'defaultCurrency',
'showValue',
'enableClickItem'
],
emits: [
'click'
],
data() {
return {
selectedLegends: null,
selectedIndex: 0
};
},
computed: {
...mapStores(useSettingsStore, useUserStore),
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
itemsMap: function () {
const map = {};
import type { ColorValue } from '@/core/color.ts';
import { ThemeType } from '@/core/theme.ts';
import { DEFAULT_ICON_COLOR } from '@/consts/color.ts';
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
let id = '';
interface DesktopPieChartDataItem extends CommonPieChartDataItem {
itemStyle: {
color: ColorValue;
};
selected: boolean;
}
if (this.idField && item[this.idField]) {
id = item[this.idField];
const props = defineProps<CommonPieChartProps>();
const emit = defineEmits<{
(e: 'click', value: Record<string, unknown>): void;
}>();
const theme = useTheme();
const { formatAmountWithCurrency } = useI18n();
const { selectedIndex, validItems } = usePieChartBase(props);
const selectedLegends = ref<Record<string, boolean> | null>(null);
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
const itemsMap = computed<Record<string, Record<string, unknown>>>(() => {
const map: Record<string, Record<string, unknown>> = {};
for (let i = 0; i < props.items.length; i++) {
const item = props.items[i];
let id = '';
if (props.idField && item[props.idField]) {
id = item[props.idField] as string;
} else {
id = item[props.nameField] as string;;
}
map[id] = item;
}
return map;
});
const seriesData = computed<DesktopPieChartDataItem[]>(() => {
const ret: DesktopPieChartDataItem[] = [];
for (let i = 0; i < validItems.value.length; i++) {
const item = validItems.value[i];
ret.push({
...item,
itemStyle: {
color: getColor(item.color),
},
selected: true
});
}
return ret;
});
const hasUnselectedItem = computed<boolean>(() => {
for (let i = 0; i < validItems.value.length; i++) {
const item = validItems.value[i];
if (selectedLegends.value && !selectedLegends.value[item.id]) {
return true;
}
}
return false;
});
const firstItemAndHalfCurrentItemTotalPercent = computed<number>(() => {
let totalValue = 0;
let firstValue = null;
let firstToCurrentTotalValue = 0;
for (let i = 0; i < validItems.value.length; i++) {
const item = validItems.value[i];
if (selectedLegends.value && !selectedLegends.value[item.id]) {
continue;
}
if (firstValue === null) {
firstValue = item.value;
}
if (firstValue !== null) {
if (i < selectedIndex.value) {
firstToCurrentTotalValue += item.value;
} else if (i === selectedIndex.value) {
firstToCurrentTotalValue += item.value / 2;
}
}
totalValue += item.value;
}
if (firstToCurrentTotalValue && totalValue > 0) {
return firstToCurrentTotalValue / totalValue;
} else {
return 0;
}
});
const chartOptions = computed<object>(() => {
return {
tooltip: {
trigger: 'item',
backgroundColor: isDarkMode.value ? '#333' : '#fff',
borderColor: isDarkMode.value ? '#333' : '#fff',
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (params: CallbackDataParams) => {
const dataItem = params.data as DesktopPieChartDataItem;
const name = dataItem ? dataItem.displayName : '';
const value = dataItem ? dataItem.displayValue : formatAmountWithCurrency(params.value as number);
let percent = dataItem ? dataItem.displayPercent : (params.percent + '%');
if (hasUnselectedItem.value) {
percent = params.percent + '%';
}
let tooltip = `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`;
if (name) {
tooltip += `<span>${name}</span><br/><span>${value} (${percent})</span>`;
} else {
id = item[this.nameField];
tooltip += `<span>${value} (${percent})</span>`;
}
map[id] = item;
}
tooltip += '</div>';
return map;
},
validItems: function () {
let totalValidValue = 0;
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item[this.valueField] && item[this.valueField] > 0 && (!this.hiddenField || !item[this.hiddenField])) {
totalValidValue += item[this.valueField];
}
}
const validItems = [];
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item[this.valueField] && item[this.valueField] > 0 &&
(!this.hiddenField || !item[this.hiddenField]) &&
(!this.minValidPercent || item[this.valueField] / totalValidValue > this.minValidPercent)) {
const finalItem = {
id: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
name: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
displayName: item[this.nameField],
value: item[this.valueField],
percent: (item[this.percentField] > 0 || item[this.percentField] === 0 || item[this.percentField] === '0') ? item[this.percentField] : (item[this.valueField] / totalValidValue * 100),
actualPercent: item[this.valueField] / totalValidValue,
itemStyle: {
color: this.getColor(item[this.colorField] ? item[this.colorField] : colorConstants.defaultChartColors[validItems.length % colorConstants.defaultChartColors.length]),
},
selected: true,
sourceItem: item
};
finalItem.displayPercent = formatPercent(finalItem.percent, 2, '&lt;0.01');
finalItem.displayValue = this.getDisplayCurrency(finalItem.value, this.defaultCurrency);
validItems.push(finalItem);
}
}
return validItems;
},
hasUnselectedItem: function () {
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends && !this.selectedLegends[item.id]) {
return true;
}
}
return false;
},
firstItemAndHalfCurrentItemTotalPercent: function () {
let totalValue = 0;
let firstValue = null;
let firstToCurrentTotalValue = 0;
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends && !this.selectedLegends[item.id]) {
continue;
}
if (firstValue === null) {
firstValue = item.value;
}
if (firstValue !== null) {
if (i < this.selectedIndex) {
firstToCurrentTotalValue += item.value;
} else if (i === this.selectedIndex) {
firstToCurrentTotalValue += item.value / 2;
}
}
totalValue += item.value;
}
if (firstToCurrentTotalValue && totalValue > 0) {
return firstToCurrentTotalValue / totalValue;
} else {
return 0;
return tooltip;
}
},
chartOptions: function () {
const self = this;
return {
tooltip: {
trigger: 'item',
backgroundColor: self.isDarkMode ? '#333' : '#fff',
borderColor: self.isDarkMode ? '#333' : '#fff',
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: params => {
const name = params.data ? params.data.displayName : '';
const value = params.data ? params.data.displayValue : self.getDisplayCurrency(params.value);
let percent = params.data ? params.data.displayPercent : (params.percent + '%');
if (self.hasUnselectedItem) {
percent = params.percent + '%';
}
let tooltip = `<div><span class="chart-pointer" style="background-color: ${params.color}"></span>`;
if (name) {
tooltip += `<span>${name}</span><br/><span>${value} (${percent})</span>`;
} else {
tooltip += `<span>${value} (${percent})</span>`;
}
tooltip += '</div>';
return tooltip;
legend: {
orient: 'horizontal',
data: validItems.value.map(item => item.name),
selected: selectedLegends.value,
textStyle: {
color: isDarkMode.value ? '#eee' : '#333'
},
formatter: (id: string) => {
const item = itemsMap.value[id];
return item && props.nameField && item[props.nameField] ? item[props.nameField] as string : id;
}
},
series: [
{
type: 'pie',
data: seriesData.value,
top: 50,
startAngle: -90 + firstItemAndHalfCurrentItemTotalPercent.value * 360,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
}
},
legend: {
orient: 'horizontal',
data: self.validItems.map(item => item.name),
selected: self.selectedLegends,
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: id => {
return self.itemsMap[id] && self.nameField && self.itemsMap[id][self.nameField] ? self.itemsMap[id][self.nameField] : id;
label: {
color: isDarkMode.value ? '#eee' : '#333',
formatter: (params: CallbackDataParams) => {
const dataItem = params.data as DesktopPieChartDataItem;
return dataItem ? dataItem.displayName : '';
}
},
series: [
{
type: 'pie',
data: self.validItems,
top: 50,
startAngle: -90 + self.firstItemAndHalfCurrentItemTotalPercent * 360,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
}
},
label: {
color: self.isDarkMode ? '#eee' : '#333',
formatter: params => {
return params.data ? params.data.displayName : '';
}
},
animation: !self.skeleton
}
],
media: [
{
query: {
minWidth: 600
},
option: {
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
type: 'pie',
top: 0
}
]
animation: !props.skeleton
}
],
media: [
{
query: {
minWidth: 600,
},
option: {
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
type: 'pie',
top: 0
}
}
]
]
},
}
]
};
});
function getColor(color: string): ColorValue {
if (color && color !== DEFAULT_ICON_COLOR) {
color = '#' + color;
}
return color;
}
function clickItem(e: ECElementEvent): void {
if (!props.enableClickItem || e.componentType !== 'series' || e.seriesType !=='pie') {
return;
}
if (e.event && e.event.target && e.event.target.currentStates && e.event.target.currentStates[0] && e.event.target.currentStates[0] === 'emphasis') {
selectedIndex.value = e.dataIndex;
return;
}
if (!e.data) {
return;
}
const data = e.data as object;
if ('sourceItem' in data) {
emit('click', data.sourceItem as Record<string, unknown>);
}
}
function onLegendSelectChanged(e: { selected: Record<string, boolean> }): void {
selectedLegends.value = e.selected;
const selectedItem = validItems.value[selectedIndex.value];
if (!selectedItem || !selectedLegends.value[selectedItem.id]) {
let newSelectedIndex = 0;
for (let i = 0; i < validItems.value.length; i++) {
const item = validItems.value[i];
if (selectedLegends.value[item.id]) {
newSelectedIndex = i;
break;
}
}
},
watch: {
'items': function () {
this.selectedIndex = 0;
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
clickItem: function (e) {
if (!this.enableClickItem || e.componentType !== 'series' || e.seriesType !=='pie') {
return;
}
if (e.event && e.event.target && e.event.target.currentStates && e.event.target.currentStates[0] && e.event.target.currentStates[0] === 'emphasis') {
this.selectedIndex = e.dataIndex;
return;
}
if (!e.data || !e.data.sourceItem) {
return;
}
this.$emit('click', e.data.sourceItem);
},
onLegendSelectChanged: function (e) {
this.selectedLegends = e.selected;
const selectedItem = this.validItems[this.selectedIndex];
if (!selectedItem || !this.selectedLegends[selectedItem.id]) {
let newSelectedIndex = 0;
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends[item.id]) {
newSelectedIndex = i;
break;
}
}
this.selectedIndex = newSelectedIndex;
}
},
getColor: function (color) {
if (color && color !== colorConstants.defaultColor) {
color = '#' + color;
}
return color;
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.formatAmountWithCurrency(this.settingsStore, this.userStore, value, currencyCode);
}
selectedIndex.value = newSelectedIndex;
}
}
</script>
@@ -25,11 +25,11 @@
</v-list>
</div>
<div class="schedule-frequency-value-container">
<v-list v-if="frequencyType === allTemplateScheduledFrequencyTypes.Disabled.type">
<v-list-item :title="$t('None')"></v-list-item>
<v-list v-if="frequencyType === ScheduledTemplateFrequencyType.Disabled.type">
<v-list-item :title="tt('None')"></v-list-item>
</v-list>
<v-list select-strategy="classic" v-model:selected="frequencyValue"
v-else-if="frequencyType === allTemplateScheduledFrequencyTypes.Weekly.type">
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Weekly.type">
<v-list-item :key="weekDay.type" :value="weekDay.type" :title="weekDay.displayName"
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(weekDay.type) }"
v-for="weekDay in allWeekDays">
@@ -39,7 +39,7 @@
</v-list-item>
</v-list>
<v-list select-strategy="classic" v-model:selected="frequencyValue"
v-else-if="frequencyType === allTemplateScheduledFrequencyTypes.Monthly.type">
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Monthly.type">
<v-list-item :key="monthDay.day" :value="monthDay.day" :title="monthDay.displayName"
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthDay.day) }"
v-for="monthDay in allAvailableMonthDays">
@@ -54,137 +54,100 @@
</v-select>
</template>
<script>
import { mapStores } from 'pinia';
import { useUserStore } from '@/stores/user.js';
<script setup lang="ts">
import { ref, computed, nextTick, useTemplateRef } from 'vue';
import templateConstants from '@/consts/template.js';
import { sortNumbersArray } from '@/lib/common.js';
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
import { useI18n } from '@/locales/helpers.ts';
import { type CommonScheduleFrequencySelectionProps, useScheduleFrequencySelectionBase } from '@/components/base/ScheduleFrequencySelectionBase.ts';
export default {
props: [
'type',
'modelValue',
'disabled',
'readonly',
'label'
],
emits: [
'update:type',
'update:modelValue'
],
data() {
return {
menuState: false
}
},
computed: {
...mapStores(useUserStore),
allTransactionScheduledFrequencyTypes() {
return this.$locale.getAllTransactionScheduledFrequencyTypes();
},
allTemplateScheduledFrequencyTypes() {
return templateConstants.allTemplateScheduledFrequencyTypes;
},
allWeekDays() {
return this.$locale.getAllWeekDays(this.firstDayOfWeek);
},
allAvailableMonthDays() {
const allAvailableDays = [];
import { useUserStore } from '@/stores/user.ts';
for (let i = 1; i <= 28; i++) {
allAvailableDays.push({
day: i,
displayName: this.$locale.getMonthdayShortName(i),
});
}
import { ScheduledTemplateFrequencyType } from '@/core/template.ts';
import { sortNumbersArray } from '@/lib/common.ts';
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
return allAvailableDays;
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
frequencyType: {
get: function () {
return this.type;
},
set: function (value) {
if (this.type !== value) {
this.$emit('update:type', value);
const props = defineProps<CommonScheduleFrequencySelectionProps>();
const emit = defineEmits<{
(e: 'update:type', value: number): void;
(e: 'update:modelValue', value: string): void;
}>();
if (value === templateConstants.allTemplateScheduledFrequencyTypes.Weekly.type) {
this.frequencyValue = [this.firstDayOfWeek];
} else if (value === templateConstants.allTemplateScheduledFrequencyTypes.Monthly.type) {
this.frequencyValue = [1];
} else {
this.frequencyValue = [];
}
}
}
},
frequencyValue: {
get: function () {
const values = this.modelValue.split(',');
const ret = [];
const { tt, getMultiMonthdayShortNames, getMultiWeekdayLongNames } = useI18n();
const { allTransactionScheduledFrequencyTypes, allWeekDays, allAvailableMonthDays, getFrequencyValues } = useScheduleFrequencySelectionBase();
for (let i = 0; i < values.length; i++) {
if (values[i]) {
ret.push(parseInt(values[i]));
}
}
const userStore = useUserStore();
return sortNumbersArray(ret);
},
set: function (value) {
this.$emit('update:modelValue', sortNumbersArray(value).join(','));
}
},
displayFrequency() {
if (this.type === templateConstants.allTemplateScheduledFrequencyTypes.Disabled.type) {
return this.$t('Disabled');
} else if (this.type === templateConstants.allTemplateScheduledFrequencyTypes.Weekly.type) {
if (this.frequencyValue.length) {
return this.$t('format.misc.everyMultiDaysOfWeek', {
days: this.$locale.getMultiWeekdayLongNames(this.frequencyValue, this.firstDayOfWeek)
});
} else {
return this.$t('Weekly');
}
} else if (this.type === templateConstants.allTemplateScheduledFrequencyTypes.Monthly.type) {
if (this.frequencyValue.length) {
return this.$t('format.misc.everyMultiDaysOfMonth', {
days: this.$locale.getMultiMonthdayShortNames(this.frequencyValue)
});
} else {
return this.$t('Monthly');
}
const dropdownMenu = useTemplateRef<HTMLElement>('dropdownMenu');
const menuState = ref<boolean>(false);
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
const frequencyType = computed<number>({
get: () => props.type,
set: (value: number) => {
if (props.type !== value) {
emit('update:type', value);
if (value === ScheduledTemplateFrequencyType.Weekly.type) {
frequencyValue.value = [firstDayOfWeek.value];
} else if (value === ScheduledTemplateFrequencyType.Monthly.type) {
frequencyValue.value = [1];
} else {
return '';
frequencyValue.value = [];
}
}
},
methods: {
onMenuStateChanged(state) {
const self = this;
}
});
if (state) {
self.$nextTick(() => {
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, '.schedule-frequency-value-container', '.frequency-value-selected');
}
});
}
},
isFrequencyValueSelected(value) {
for (let i = 0; i < this.frequencyValue.length; i++) {
if (this.frequencyValue[i] === value) {
return true;
}
}
const frequencyValue = computed<number[]>({
get: () => getFrequencyValues(props.modelValue),
set: (value: number[]) => {
emit('update:modelValue', sortNumbersArray(value).join(','));
}
});
return false;
const displayFrequency = computed<string>(() => {
if (frequencyType.value === ScheduledTemplateFrequencyType.Disabled.type) {
return tt('Disabled');
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Weekly.type) {
if (frequencyValue.value.length) {
return tt('format.misc.everyMultiDaysOfWeek', {
days: getMultiWeekdayLongNames(frequencyValue.value, firstDayOfWeek.value)
});
} else {
return tt('Weekly');
}
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Monthly.type) {
if (frequencyValue.value.length) {
return tt('format.misc.everyMultiDaysOfMonth', {
days: getMultiMonthdayShortNames(frequencyValue.value)
});
} else {
return tt('Monthly');
}
} else {
return '';
}
});
function isFrequencyValueSelected(value: number): boolean {
for (let i = 0; i < frequencyValue.value.length; i++) {
if (frequencyValue.value[i] === value) {
return true;
}
}
return false;
}
function onMenuStateChanged(state: boolean): void {
if (state) {
nextTick(() => {
if (dropdownMenu.value && dropdownMenu.value.parentElement) {
scrollToSelectedItem(dropdownMenu.value.parentElement, '.schedule-frequency-value-container', '.frequency-value-selected');
}
});
}
}
</script>

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