From 11052951b74b9ad4b6a9412ae490086235f9154b Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 3 Jan 2021 13:40:40 +0100 Subject: Rewrite in Rust --- .gitignore | 13 +- .rustfmt.toml | 2 + .stylish-haskell.yaml | 34 - .tmuxinator.yml | 14 - Cargo.lock | 2675 +++++++++++++++++++++++++ Cargo.toml | 26 + Makefile | 62 - README.md | 68 +- application.conf | 9 - assets/chart.js | 7 + assets/icon.png | Bin 0 -> 1203 bytes assets/main.css | 399 ++++ assets/main.js | 226 +++ bin/db | 26 + bin/watch | 15 + cabal-client.project | 4 - cabal-server.project | 3 - client/LICENSE | 674 ------- client/Setup.hs | 2 - client/client.cabal | 90 - client/src/Component/Appearing.hs | 10 - client/src/Component/Button.hs | 57 - client/src/Component/ConfirmDialog.hs | 49 - client/src/Component/Form.hs | 12 - client/src/Component/Input.hs | 151 -- client/src/Component/Link.hs | 33 - client/src/Component/Modal.hs | 117 -- client/src/Component/ModalForm.hs | 71 - client/src/Component/Pages.hs | 86 - client/src/Component/Select.hs | 80 - client/src/Component/Table.hs | 105 - client/src/Component/Tag.hs | 27 - client/src/Loadable.hs | 109 - client/src/Main.hs | 39 - client/src/Model/Route.hs | 11 - client/src/Util/Ajax.hs | 139 -- client/src/Util/Css.hs | 9 - client/src/Util/Either.hs | 7 - client/src/Util/Reflex.hs | 59 - client/src/Util/Router.hs | 266 --- client/src/Util/Validation.hs | 36 - client/src/Util/WaitFor.hs | 17 - client/src/View/App.hs | 108 - client/src/View/Category/Category.hs | 94 - client/src/View/Category/Form.hs | 117 -- client/src/View/Category/Reducer.hs | 59 - client/src/View/Category/Table.hs | 93 - client/src/View/Header.hs | 123 -- client/src/View/Icon.hs | 71 - client/src/View/Income/Form.hs | 119 -- client/src/View/Income/Header.hs | 77 - client/src/View/Income/Income.hs | 75 - client/src/View/Income/Reducer.hs | 59 - client/src/View/Income/Table.hs | 93 - client/src/View/NotFound.hs | 20 - client/src/View/Payment/Form.hs | 199 -- client/src/View/Payment/HeaderForm.hs | 85 - client/src/View/Payment/HeaderInfos.hs | 94 - client/src/View/Payment/Payment.hs | 101 - client/src/View/Payment/Reducer.hs | 110 - client/src/View/Payment/Table.hs | 145 -- client/src/View/SignIn.hs | 82 - client/src/View/Statistics/Chart.hs | 102 - client/src/View/Statistics/Statistics.hs | 85 - common/LICENSE | 674 ------- common/Setup.hs | 2 - common/common.cabal | 72 - common/src/Common/Message/Key.hs | 150 -- common/src/Common/Message/Lang.hs | 7 - common/src/Common/Message/Translation.hs | 655 ------ common/src/Common/Model.hs | 26 - common/src/Common/Model/Category.hs | 24 - common/src/Common/Model/CategoryPage.hs | 18 - common/src/Common/Model/CreateCategoryForm.hs | 15 - common/src/Common/Model/CreateIncomeForm.hs | 15 - common/src/Common/Model/CreatePaymentForm.hs | 21 - common/src/Common/Model/Currency.hs | 12 - common/src/Common/Model/EditCategoryForm.hs | 18 - common/src/Common/Model/EditIncome.hs | 17 - common/src/Common/Model/EditIncomeForm.hs | 18 - common/src/Common/Model/EditPaymentForm.hs | 23 - common/src/Common/Model/Email.hs | 12 - common/src/Common/Model/ExceedingPayer.hs | 16 - common/src/Common/Model/Frequency.hs | 14 - common/src/Common/Model/Income.hs | 27 - common/src/Common/Model/IncomeHeader.hs | 18 - common/src/Common/Model/IncomePage.hs | 19 - common/src/Common/Model/Init.hs | 18 - common/src/Common/Model/Password.hs | 12 - common/src/Common/Model/Payment.hs | 33 - common/src/Common/Model/PaymentHeader.hs | 18 - common/src/Common/Model/PaymentPage.hs | 21 - common/src/Common/Model/SignInForm.hs | 15 - common/src/Common/Model/Stats.hs | 23 - common/src/Common/Model/User.hs | 27 - common/src/Common/Msg.hs | 13 - common/src/Common/Util/Text.hs | 49 - common/src/Common/Util/Time.hs | 26 - common/src/Common/Util/Validation.hs | 13 - common/src/Common/Validation/Atomic.hs | 61 - common/src/Common/Validation/Category.hs | 15 - common/src/Common/Validation/Income.hs | 17 - common/src/Common/Validation/Payment.hs | 31 - common/src/Common/Validation/SignIn.hs | 17 - common/src/Common/View/Format.hs | 78 - config.json | 4 + default.nix | 25 - docs/balance.png | Bin 0 -> 61703 bytes docs/payments.png | Bin 0 -> 141443 bytes docs/statistics.png | Bin 0 -> 79563 bytes nix/nixpkgs.nix | 6 - nix/tools.nix | 15 - public/css/reset.css | 60 - public/images/icon.png | Bin 1203 -> 0 bytes public/javascript/.gitkeep | 0 server/LICENSE | 674 ------- server/Setup.hs | 2 - server/migrations/1.sql | 65 - server/migrations/2.sql | 44 - server/migrations/3.sql | 5 - server/server.cabal | 131 -- server/src/Conf.hs | 39 - server/src/Controller/Category.hs | 88 - server/src/Controller/Helper.hs | 16 - server/src/Controller/Income.hs | 90 - server/src/Controller/Index.hs | 76 - server/src/Controller/Payment.hs | 118 -- server/src/Controller/Statistics.hs | 21 - server/src/Controller/User.hs | 17 - server/src/Cookie.hs | 55 - server/src/Design/Appearing.hs | 25 - server/src/Design/Color.hs | 40 - server/src/Design/Constants.hs | 27 - server/src/Design/Errors.hs | 53 - server/src/Design/Form.hs | 101 - server/src/Design/Global.hs | 165 -- server/src/Design/Helper.hs | 48 - server/src/Design/Loadable.hs | 29 - server/src/Design/Media.hs | 36 - server/src/Design/Modal.hs | 69 - server/src/Design/Tooltip.hs | 14 - server/src/Design/View/ConfirmDialog.hs | 36 - server/src/Design/View/Header.hs | 93 - server/src/Design/View/NotFound.hs | 21 - server/src/Design/View/Pages.hs | 55 - server/src/Design/View/Payment.hs | 15 - server/src/Design/View/Payment/Add.hs | 35 - server/src/Design/View/Payment/Form.hs | 35 - server/src/Design/View/Payment/HeaderForm.hs | 40 - server/src/Design/View/Payment/HeaderInfos.hs | 50 - server/src/Design/View/SignIn.hs | 36 - server/src/Design/View/Stat.hs | 17 - server/src/Design/View/Table.hs | 99 - server/src/Design/Views.hs | 56 - server/src/Job/Daemon.hs | 37 - server/src/Job/Frequency.hs | 13 - server/src/Job/Kind.hs | 23 - server/src/Job/Model.hs | 49 - server/src/Job/MonthlyPayment.hs | 26 - server/src/Job/WeeklyReport.hs | 52 - server/src/LoginSession.hs | 52 - server/src/Main.hs | 106 - server/src/Model/CreateCategory.hs | 10 - server/src/Model/CreateIncome.hs | 10 - server/src/Model/CreatePayment.hs | 16 - server/src/Model/EditCategory.hs | 13 - server/src/Model/EditIncome.hs | 13 - server/src/Model/EditPayment.hs | 17 - server/src/Model/HashedPassword.hs | 27 - server/src/Model/IncomeResource.hs | 15 - server/src/Model/Mail.hs | 12 - server/src/Model/PaymentResource.hs | 15 - server/src/Model/Query.hs | 32 - server/src/Model/SignIn.hs | 10 - server/src/Model/UUID.hs | 10 - server/src/Payer.hs | 87 - server/src/Persistence/Category.hs | 123 -- server/src/Persistence/Frequency.hs | 23 - server/src/Persistence/Income.hs | 201 -- server/src/Persistence/Payment.hs | 389 ---- server/src/Persistence/User.hs | 78 - server/src/Persistence/Util.hs | 11 - server/src/Resource.hs | 54 - server/src/Secure.hs | 31 - server/src/SendMail.hs | 66 - server/src/Statistics.hs | 59 - server/src/Util/Time.hs | 22 - server/src/Validation/Category.hs | 27 - server/src/Validation/Income.hs | 27 - server/src/Validation/Payment.hs | 33 - server/src/Validation/SignIn.hs | 16 - server/src/View/Mail/WeeklyReport.hs | 124 -- server/src/View/Page.hs | 43 - shell.nix | 30 + sql/fixtures.sql | 45 + sql/migrations/1.sql | 65 + sql/migrations/2.sql | 44 + sql/migrations/3.sql | 5 + sql/migrations/4.sql | 91 + src/assets.rs | 27 + src/controller/balance.rs | 71 + src/controller/categories.rs | 141 ++ src/controller/error.rs | 31 + src/controller/incomes.rs | 221 ++ src/controller/login.rs | 86 + src/controller/mod.rs | 9 + src/controller/payments.rs | 227 +++ src/controller/statistics.rs | 30 + src/controller/utils.rs | 119 ++ src/controller/wallet.rs | 13 + src/db/categories.rs | 132 ++ src/db/incomes.rs | 494 +++++ src/db/jobs.rs | 56 + src/db/mod.rs | 6 + src/db/payments.rs | 525 +++++ src/db/users.rs | 144 ++ src/db/utils.rs | 3 + src/jobs/jobs.rs | 28 + src/jobs/mod.rs | 2 + src/jobs/weekly_report.rs | 55 + src/mail.rs | 59 + src/main.rs | 88 + src/model/action.rs | 1 + src/model/category.rs | 20 + src/model/config.rs | 8 + src/model/frequency.rs | 31 + src/model/income.rs | 40 + src/model/job.rs | 5 + src/model/login.rs | 5 + src/model/mod.rs | 9 + src/model/payment.rs | 53 + src/model/report.rs | 16 + src/model/user.rs | 8 + src/payer.rs | 38 + src/queries.rs | 62 + src/routes.rs | 219 ++ src/templates.rs | 97 + src/utils/mod.rs | 1 + src/utils/text.rs | 19 + src/validation/category.rs | 18 + src/validation/income.rs | 22 + src/validation/login.rs | 11 + src/validation/mod.rs | 5 + src/validation/payment.rs | 25 + src/validation/utils.rs | 54 + templates/balance.html | 107 + templates/base.html | 91 + templates/category/create.html | 51 + templates/category/table.html | 38 + templates/category/update.html | 85 + templates/error.html | 17 + templates/income/create.html | 89 + templates/income/table.html | 55 + templates/income/update.html | 117 ++ templates/login.html | 29 + templates/macros/paging.html | 55 + templates/payment/create.html | 120 ++ templates/payment/table.html | 128 ++ templates/payment/update.html | 144 ++ templates/report/list.j2 | 14 + templates/report/report.j2 | 50 + templates/statistics.html | 29 + validation/LICENSE | 674 ------- validation/Setup.hs | 2 - validation/src/Data/Validation.hs | 375 ---- validation/validation.cabal | 23 - 266 files changed, 8240 insertions(+), 12695 deletions(-) create mode 100644 .rustfmt.toml delete mode 100644 .stylish-haskell.yaml delete mode 100644 .tmuxinator.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 Makefile delete mode 100644 application.conf create mode 100644 assets/chart.js create mode 100644 assets/icon.png create mode 100644 assets/main.css create mode 100644 assets/main.js create mode 100755 bin/db create mode 100755 bin/watch delete mode 100644 cabal-client.project delete mode 100644 cabal-server.project delete mode 100644 client/LICENSE delete mode 100644 client/Setup.hs delete mode 100644 client/client.cabal delete mode 100644 client/src/Component/Appearing.hs delete mode 100644 client/src/Component/Button.hs delete mode 100644 client/src/Component/ConfirmDialog.hs delete mode 100644 client/src/Component/Form.hs delete mode 100644 client/src/Component/Input.hs delete mode 100644 client/src/Component/Link.hs delete mode 100644 client/src/Component/Modal.hs delete mode 100644 client/src/Component/ModalForm.hs delete mode 100644 client/src/Component/Pages.hs delete mode 100644 client/src/Component/Select.hs delete mode 100644 client/src/Component/Table.hs delete mode 100644 client/src/Component/Tag.hs delete mode 100644 client/src/Loadable.hs delete mode 100644 client/src/Main.hs delete mode 100644 client/src/Model/Route.hs delete mode 100644 client/src/Util/Ajax.hs delete mode 100644 client/src/Util/Css.hs delete mode 100644 client/src/Util/Either.hs delete mode 100644 client/src/Util/Reflex.hs delete mode 100644 client/src/Util/Router.hs delete mode 100644 client/src/Util/Validation.hs delete mode 100644 client/src/Util/WaitFor.hs delete mode 100644 client/src/View/App.hs delete mode 100644 client/src/View/Category/Category.hs delete mode 100644 client/src/View/Category/Form.hs delete mode 100644 client/src/View/Category/Reducer.hs delete mode 100644 client/src/View/Category/Table.hs delete mode 100644 client/src/View/Header.hs delete mode 100644 client/src/View/Icon.hs delete mode 100644 client/src/View/Income/Form.hs delete mode 100644 client/src/View/Income/Header.hs delete mode 100644 client/src/View/Income/Income.hs delete mode 100644 client/src/View/Income/Reducer.hs delete mode 100644 client/src/View/Income/Table.hs delete mode 100644 client/src/View/NotFound.hs delete mode 100644 client/src/View/Payment/Form.hs delete mode 100644 client/src/View/Payment/HeaderForm.hs delete mode 100644 client/src/View/Payment/HeaderInfos.hs delete mode 100644 client/src/View/Payment/Payment.hs delete mode 100644 client/src/View/Payment/Reducer.hs delete mode 100644 client/src/View/Payment/Table.hs delete mode 100644 client/src/View/SignIn.hs delete mode 100644 client/src/View/Statistics/Chart.hs delete mode 100644 client/src/View/Statistics/Statistics.hs delete mode 100644 common/LICENSE delete mode 100644 common/Setup.hs delete mode 100644 common/common.cabal delete mode 100644 common/src/Common/Message/Key.hs delete mode 100644 common/src/Common/Message/Lang.hs delete mode 100644 common/src/Common/Message/Translation.hs delete mode 100644 common/src/Common/Model.hs delete mode 100644 common/src/Common/Model/Category.hs delete mode 100644 common/src/Common/Model/CategoryPage.hs delete mode 100644 common/src/Common/Model/CreateCategoryForm.hs delete mode 100644 common/src/Common/Model/CreateIncomeForm.hs delete mode 100644 common/src/Common/Model/CreatePaymentForm.hs delete mode 100644 common/src/Common/Model/Currency.hs delete mode 100644 common/src/Common/Model/EditCategoryForm.hs delete mode 100644 common/src/Common/Model/EditIncome.hs delete mode 100644 common/src/Common/Model/EditIncomeForm.hs delete mode 100644 common/src/Common/Model/EditPaymentForm.hs delete mode 100644 common/src/Common/Model/Email.hs delete mode 100644 common/src/Common/Model/ExceedingPayer.hs delete mode 100644 common/src/Common/Model/Frequency.hs delete mode 100644 common/src/Common/Model/Income.hs delete mode 100644 common/src/Common/Model/IncomeHeader.hs delete mode 100644 common/src/Common/Model/IncomePage.hs delete mode 100644 common/src/Common/Model/Init.hs delete mode 100644 common/src/Common/Model/Password.hs delete mode 100644 common/src/Common/Model/Payment.hs delete mode 100644 common/src/Common/Model/PaymentHeader.hs delete mode 100644 common/src/Common/Model/PaymentPage.hs delete mode 100644 common/src/Common/Model/SignInForm.hs delete mode 100644 common/src/Common/Model/Stats.hs delete mode 100644 common/src/Common/Model/User.hs delete mode 100644 common/src/Common/Msg.hs delete mode 100644 common/src/Common/Util/Text.hs delete mode 100644 common/src/Common/Util/Time.hs delete mode 100644 common/src/Common/Util/Validation.hs delete mode 100644 common/src/Common/Validation/Atomic.hs delete mode 100644 common/src/Common/Validation/Category.hs delete mode 100644 common/src/Common/Validation/Income.hs delete mode 100644 common/src/Common/Validation/Payment.hs delete mode 100644 common/src/Common/Validation/SignIn.hs delete mode 100644 common/src/Common/View/Format.hs create mode 100644 config.json delete mode 100644 default.nix create mode 100644 docs/balance.png create mode 100644 docs/payments.png create mode 100644 docs/statistics.png delete mode 100644 nix/nixpkgs.nix delete mode 100644 nix/tools.nix delete mode 100644 public/css/reset.css delete mode 100644 public/images/icon.png delete mode 100644 public/javascript/.gitkeep delete mode 100644 server/LICENSE delete mode 100644 server/Setup.hs delete mode 100644 server/migrations/1.sql delete mode 100644 server/migrations/2.sql delete mode 100644 server/migrations/3.sql delete mode 100644 server/server.cabal delete mode 100644 server/src/Conf.hs delete mode 100644 server/src/Controller/Category.hs delete mode 100644 server/src/Controller/Helper.hs delete mode 100644 server/src/Controller/Income.hs delete mode 100644 server/src/Controller/Index.hs delete mode 100644 server/src/Controller/Payment.hs delete mode 100644 server/src/Controller/Statistics.hs delete mode 100644 server/src/Controller/User.hs delete mode 100644 server/src/Cookie.hs delete mode 100644 server/src/Design/Appearing.hs delete mode 100644 server/src/Design/Color.hs delete mode 100644 server/src/Design/Constants.hs delete mode 100644 server/src/Design/Errors.hs delete mode 100644 server/src/Design/Form.hs delete mode 100644 server/src/Design/Global.hs delete mode 100644 server/src/Design/Helper.hs delete mode 100644 server/src/Design/Loadable.hs delete mode 100644 server/src/Design/Media.hs delete mode 100644 server/src/Design/Modal.hs delete mode 100644 server/src/Design/Tooltip.hs delete mode 100644 server/src/Design/View/ConfirmDialog.hs delete mode 100644 server/src/Design/View/Header.hs delete mode 100644 server/src/Design/View/NotFound.hs delete mode 100644 server/src/Design/View/Pages.hs delete mode 100644 server/src/Design/View/Payment.hs delete mode 100644 server/src/Design/View/Payment/Add.hs delete mode 100644 server/src/Design/View/Payment/Form.hs delete mode 100644 server/src/Design/View/Payment/HeaderForm.hs delete mode 100644 server/src/Design/View/Payment/HeaderInfos.hs delete mode 100644 server/src/Design/View/SignIn.hs delete mode 100644 server/src/Design/View/Stat.hs delete mode 100644 server/src/Design/View/Table.hs delete mode 100644 server/src/Design/Views.hs delete mode 100644 server/src/Job/Daemon.hs delete mode 100644 server/src/Job/Frequency.hs delete mode 100644 server/src/Job/Kind.hs delete mode 100644 server/src/Job/Model.hs delete mode 100644 server/src/Job/MonthlyPayment.hs delete mode 100644 server/src/Job/WeeklyReport.hs delete mode 100644 server/src/LoginSession.hs delete mode 100644 server/src/Main.hs delete mode 100644 server/src/Model/CreateCategory.hs delete mode 100644 server/src/Model/CreateIncome.hs delete mode 100644 server/src/Model/CreatePayment.hs delete mode 100644 server/src/Model/EditCategory.hs delete mode 100644 server/src/Model/EditIncome.hs delete mode 100644 server/src/Model/EditPayment.hs delete mode 100644 server/src/Model/HashedPassword.hs delete mode 100644 server/src/Model/IncomeResource.hs delete mode 100644 server/src/Model/Mail.hs delete mode 100644 server/src/Model/PaymentResource.hs delete mode 100644 server/src/Model/Query.hs delete mode 100644 server/src/Model/SignIn.hs delete mode 100644 server/src/Model/UUID.hs delete mode 100644 server/src/Payer.hs delete mode 100644 server/src/Persistence/Category.hs delete mode 100644 server/src/Persistence/Frequency.hs delete mode 100644 server/src/Persistence/Income.hs delete mode 100644 server/src/Persistence/Payment.hs delete mode 100644 server/src/Persistence/User.hs delete mode 100644 server/src/Persistence/Util.hs delete mode 100644 server/src/Resource.hs delete mode 100644 server/src/Secure.hs delete mode 100644 server/src/SendMail.hs delete mode 100644 server/src/Statistics.hs delete mode 100644 server/src/Util/Time.hs delete mode 100644 server/src/Validation/Category.hs delete mode 100644 server/src/Validation/Income.hs delete mode 100644 server/src/Validation/Payment.hs delete mode 100644 server/src/Validation/SignIn.hs delete mode 100644 server/src/View/Mail/WeeklyReport.hs delete mode 100644 server/src/View/Page.hs create mode 100644 shell.nix create mode 100644 sql/fixtures.sql create mode 100644 sql/migrations/1.sql create mode 100644 sql/migrations/2.sql create mode 100644 sql/migrations/3.sql create mode 100644 sql/migrations/4.sql create mode 100644 src/assets.rs create mode 100644 src/controller/balance.rs create mode 100644 src/controller/categories.rs create mode 100644 src/controller/error.rs create mode 100644 src/controller/incomes.rs create mode 100644 src/controller/login.rs create mode 100644 src/controller/mod.rs create mode 100644 src/controller/payments.rs create mode 100644 src/controller/statistics.rs create mode 100644 src/controller/utils.rs create mode 100644 src/controller/wallet.rs create mode 100644 src/db/categories.rs create mode 100644 src/db/incomes.rs create mode 100644 src/db/jobs.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/payments.rs create mode 100644 src/db/users.rs create mode 100644 src/db/utils.rs create mode 100644 src/jobs/jobs.rs create mode 100644 src/jobs/mod.rs create mode 100644 src/jobs/weekly_report.rs create mode 100644 src/mail.rs create mode 100644 src/main.rs create mode 100644 src/model/action.rs create mode 100644 src/model/category.rs create mode 100644 src/model/config.rs create mode 100644 src/model/frequency.rs create mode 100644 src/model/income.rs create mode 100644 src/model/job.rs create mode 100644 src/model/login.rs create mode 100644 src/model/mod.rs create mode 100644 src/model/payment.rs create mode 100644 src/model/report.rs create mode 100644 src/model/user.rs create mode 100644 src/payer.rs create mode 100644 src/queries.rs create mode 100644 src/routes.rs create mode 100644 src/templates.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/text.rs create mode 100644 src/validation/category.rs create mode 100644 src/validation/income.rs create mode 100644 src/validation/login.rs create mode 100644 src/validation/mod.rs create mode 100644 src/validation/payment.rs create mode 100644 src/validation/utils.rs create mode 100644 templates/balance.html create mode 100644 templates/base.html create mode 100644 templates/category/create.html create mode 100644 templates/category/table.html create mode 100644 templates/category/update.html create mode 100644 templates/error.html create mode 100644 templates/income/create.html create mode 100644 templates/income/table.html create mode 100644 templates/income/update.html create mode 100644 templates/login.html create mode 100644 templates/macros/paging.html create mode 100644 templates/payment/create.html create mode 100644 templates/payment/table.html create mode 100644 templates/payment/update.html create mode 100644 templates/report/list.j2 create mode 100644 templates/report/report.j2 create mode 100644 templates/statistics.html delete mode 100644 validation/LICENSE delete mode 100644 validation/Setup.hs delete mode 100644 validation/src/Data/Validation.hs delete mode 100644 validation/validation.cabal diff --git a/.gitignore b/.gitignore index 19d08a2..c9f71b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,2 @@ -database -database-shm -database-wal -dist-server -dist-client -local.conf -public/javascript/main.js -result-client -result-server -sessionKey -.ghc.environment.* +/database.db* +/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..0c2ca5c --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2018" +max_width = 80 diff --git a/.stylish-haskell.yaml b/.stylish-haskell.yaml deleted file mode 100644 index 82305b9..0000000 --- a/.stylish-haskell.yaml +++ /dev/null @@ -1,34 +0,0 @@ -steps: - - simple_align: - cases: true - top_level_patterns: true - records: true - - - imports: - align: global - list_align: after_alias - pad_module_names: true - long_list_align: inline - empty_list_align: inherit - list_padding: 4 - separate_lists: true - space_surround: false - - - language_pragmas: - style: vertical - align: true - remove_redundant: true - - - trailing_whitespace: {} - -columns: 80 - -newline: native - -language_extensions: - - ExistentialQuantification - - LambdaCase - - MultiParamTypeClasses - - OverloadedStrings - - RecursiveDo - - ScopedTypeVariables diff --git a/.tmuxinator.yml b/.tmuxinator.yml deleted file mode 100644 index 2a765c0..0000000 --- a/.tmuxinator.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: sharedCost -startup_window: app - -windows: - - console: - - clear - - app: - panes: - - server: - - make watch-server - - client: - - make watch-client - - db: - - sqlite3 database diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f40e23e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2675 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "ahash" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" + +[[package]] +name = "ahash" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75b7e6a93ecd6dbd2c225154d0fa7f86205574ecaa6c87429fb5f66ee677c44" +dependencies = [ + "getrandom 0.2.0", + "lazy_static", + "version_check 0.9.2", +] + +[[package]] +name = "aho-corasick" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "atoi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616896e05fc0e2649463a93a15183c6a16bf03413a7af88ef1285ddedfa9cda5" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "autocfg" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "base64" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489d6c0ed21b11d038c31b6ceccca973e65d73ba3bd8ecb9a2babf5546164643" +dependencies = [ + "byteorder", + "safemem", +] + +[[package]] +name = "base64" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b25d992356d2eb0ed82172f5248873db5560c4721f564b13cb5193bda5e668e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bcrypt" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d0faafe9e089674fc3efdb311ff5253d445c79d85d1d28bd3ace76d45e7164" +dependencies = [ + "base64 0.13.0", + "blowfish", + "getrandom 0.2.0", +] + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bitvec" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ba35e9565969edb811639dbebfe34edc0368e472c5018474c8eb2543397f81" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array 0.12.3", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array 0.14.4", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + +[[package]] +name = "blowfish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fa6a061124e37baba002e496d203e23ba3d7b73750be82dbfbc92913048a5b" +dependencies = [ + "byteorder", + "cipher", + "opaque-debug 0.3.0", +] + +[[package]] +name = "bstr" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473fc6b38233f9af7baa94fb5852dca389e3d95b8e21c8e3719301462c5d9faf" +dependencies = [ + "memchr", +] + +[[package]] +name = "budget" +version = "0.1.0" +dependencies = [ + "bcrypt", + "chrono", + "env_logger", + "hyper", + "lettre", + "lettre_email", + "log", + "serde", + "serde_json", + "serde_urlencoded", + "sha2", + "sqlx", + "sqlx-core", + "structopt", + "tera", + "tokio", + "tokio-util", + "url", + "uuid 0.8.1", +] + +[[package]] +name = "bufstream" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" + +[[package]] +name = "build_const" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39092a32794787acd8525ee150305ff051b0aa6cc2abaf193924f5ab05425f39" + +[[package]] +name = "bumpalo" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820" + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "byteorder" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" + +[[package]] +name = "bytes" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" + +[[package]] +name = "cargo_metadata" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83f95cf4bf0dda0ac2e65371ae7215d0dce3c187613a9dbf23aaa9374186f97a" +dependencies = [ + "semver", + "semver-parser", + "serde", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c0496836a84f8d0495758516b8621a622beb77c0fed418570e50764093ced48" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi 0.3.9", +] + +[[package]] +name = "chrono-tz" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2554a3155fec064362507487171dcc4edc3df60cb10f3a1fb10ed8094822b120" +dependencies = [ + "chrono", + "parse-zoneinfo", +] + +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array 0.14.4", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "core-foundation" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a89e2ae426ea83155dccf10c0fa6b1463ef6d5fcb44cee0b224a408fa640a62" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" + +[[package]] +name = "cpuid-bool" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aebca1129a03dc6dc2b127edd729435bbc4a37e1d5f4d7513165089ceb02634" + +[[package]] +name = "crc" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d663548de7f5cca343f1e0a48d14dcfb0e9eb4e079ec58883b7251539fa10aeb" +dependencies = [ + "build_const", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca26ee1f8d361640700bde38b2c37d8c22b3ce2d360e1fc1c74ea4b0aa7d775" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6cb3c7f5b8e51bc3ebb73a2327ad4abdbd119dc13223f14f961d2f38486756" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d96d1e189ef58269ebe5b97953da3274d83a93af647c2ddd6f9dab28cedb8d" +dependencies = [ + "autocfg 1.0.1", + "cfg-if 1.0.0", + "lazy_static", +] + +[[package]] +name = "deunicode" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690" + +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.3", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array 0.14.4", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + +[[package]] +name = "email" +version = "0.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91549a51bb0241165f13d57fc4c72cef063b4088fb078b019ecbf464a45f22e4" +dependencies = [ + "base64 0.9.3", + "chrono", + "encoding", + "lazy_static", + "rand 0.4.6", + "time", + "version_check 0.1.5", +] + +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + +[[package]] +name = "env_logger" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26ecb66b4bdca6c1409b40fb255eefc2bd4f6d135dab3c3124f80ffa2a9661e" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece68d15c92e84fa4f19d3780f1294e5ca82a78a6d515f1efaabcc144688be00" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "fuchsia-zircon" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" +dependencies = [ + "bitflags", + "fuchsia-zircon-sys", +] + +[[package]] +name = "fuchsia-zircon-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" + +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + +[[package]] +name = "futures" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3b0c040a1fe6529d30b3c5944b280c7f0dcb2930d2c3062bca967b602583d0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b7109687aa4e177ef6fe84553af6280ef2778bdb7783ba44c9dc3399110fe64" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847ce131b72ffb13b6109a221da9ad97a64cbe48feb1028356b836b47b8f1748" + +[[package]] +name = "futures-executor" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4caa2b2b68b880003057c1dd49f1ed937e38f22fcf6c212188a121f08cf40a65" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611834ce18aaa1bd13c4b374f5d653e1027cf99b6b502584ff8c9a64413b30bb" + +[[package]] +name = "futures-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77408a692f1f97bcc61dc001d752e00643408fbc922e4d634c655df50d595556" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f878195a49cee50e006b02b93cf7e0a95a38ac7b776b4c4d9cc1207cd20fcb3d" + +[[package]] +name = "futures-task" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c554eb5bf48b2426c4771ab68c6b14468b6e76cc90996f528c3338d761a4d0d" +dependencies = [ + "once_cell", +] + +[[package]] +name = "futures-util" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d304cff4a7b99cfb7986f7d43fbe93d175e72e704a8860787cc95e9ffd85cbd2" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project 1.0.2", + "pin-utils", + "proc-macro-hack", + "proc-macro-nested", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +dependencies = [ + "typenum", + "version_check 0.9.2", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8025cf36f917e6a52cce185b7c7177689b838b7ec138364e50cc2277a56cf4" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "globset" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c152169ef1e421390738366d2f796655fec62621dabbd0fd476f905934061e4a" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "h2" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4728fd124914ad25e99e3d15a9361a879f6620f63cb56bbb08f95abb97a535" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", + "tracing-futures", +] + +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash 0.4.7", +] + +[[package]] +name = "hashlink" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99cf782f0dc4372d26846bec3de7804ceb5df083c2d4462c0b8d2330e894fa8" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644f9158b2f133fd50f5fb3242878846d9eb792e445c893805ff0e3824006e35" + +[[package]] +name = "hostname" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ceb46a83a85e824ef93669c8b390009623863b5c195d1ba747292c0c72f94e" +dependencies = [ + "libc", + "winutil", +] + +[[package]] +name = "http" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84129d298a6d57d246960ff8eb831ca4af3f96d29e2e28848dae275408658e26" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httparse" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd179ae861f0c2e53da70d892f5f3029f9594be0c41dc5269cd371691b1dc2f9" + +[[package]] +name = "httpdate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" + +[[package]] +name = "humansize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cab2627acfc432780848602f3f558f7e9dd427352224b0d9324025796d2a5e" + +[[package]] +name = "humantime" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1ad908cc71012b7bea4d0c53ba96a8cba9962f048fa68d143376143d863b7a" + +[[package]] +name = "hyper" +version = "0.13.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ad767baac13b44d4529fcf58ba2cd0995e36e7b435bc5b039de6f47e880dbf" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project 1.0.2", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "idna" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "ignore" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b287fb45c60bb826a0dc68ff08742b9d88a2fea13d6e0c286b3172065aaf878c" +dependencies = [ + "crossbeam-utils", + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1fa934250de4de8aef298d81c729a7d33d8c239daa3a7575e6b92bfc7313b" +dependencies = [ + "autocfg 1.0.1", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "js-sys" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d7383929f7c9c7c2d0fa596f325832df98c3704f2c60553080f7127a58175" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lettre" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338d9a248c4b3ef60c51941c678bb8f64e244c0a98f1eb71db027d1e777a5700" +dependencies = [ + "base64 0.10.1", + "bufstream", + "fast_chemail", + "hostname", + "log", + "native-tls", + "nom 4.2.3", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lettre_email" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd02480f8dcf48798e62113974d6ccca2129a51d241fa20f1ea349c8a42559d5" +dependencies = [ + "base64 0.10.1", + "email", + "lettre", + "mime", + "time", + "uuid 0.7.4", +] + +[[package]] +name = "lexical-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db65c6da02e61f55dae90a0ae427b2a5f6b3e8db09f58d10efab23af92592616" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if 0.1.10", + "ryu", + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb" + +[[package]] +name = "libsqlite3-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d31059f22935e6c31830db5249ba2b7ecd54fd73a9909286f0a67aa55c2fbd" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd96ffd135b2fd7b973ac026d28085defbe8983df057ced3eb4f2130b0831312" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" +dependencies = [ + "cfg-if 0.1.10", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + +[[package]] +name = "memchr" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "mio" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" +dependencies = [ + "cfg-if 0.1.10", + "fuchsia-zircon", + "fuchsia-zircon-sys", + "iovec", + "kernel32-sys", + "libc", + "log", + "miow", + "net2", + "slab", + "winapi 0.2.8", +] + +[[package]] +name = "mio-uds" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" +dependencies = [ + "iovec", + "libc", + "mio", +] + +[[package]] +name = "miow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" +dependencies = [ + "kernel32-sys", + "net2", + "winapi 0.2.8", + "ws2_32-sys", +] + +[[package]] +name = "native-tls" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "net2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391630d12b68002ae1e25e8f974306474966550ad82dac6886fb8910c19568ae" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "nom" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad2a91a8e869eeb30b9cb3119ae87773a8f4ae617f41b1eb9c154b2905f7bd6" +dependencies = [ + "memchr", + "version_check 0.1.5", +] + +[[package]] +name = "nom" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88034cfd6b4a0d54dd14f4a507eceee36c0b70e5a02236c4e4df571102be17f0" +dependencies = [ + "bitvec", + "lexical-core", + "memchr", + "version_check 0.9.2", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg 1.0.1", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg 1.0.1", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0" + +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "openssl" +version = "0.10.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038d43985d1ddca7a9900630d8cd031b56e4794eecc2e9ea39dd17aa04399a70" +dependencies = [ + "bitflags", + "cfg-if 1.0.0", + "foreign-types", + "lazy_static", + "libc", + "openssl-sys", +] + +[[package]] +name = "openssl-probe" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" + +[[package]] +name = "openssl-sys" +version = "0.9.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921fc71883267538946025deffb622905ecad223c28efbfdef9bb59a0175f3e6" +dependencies = [ + "autocfg 1.0.1", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ccb628cad4f84851442432c60ad8e1f607e29752d0bf072cbd0baf28aa34272" +dependencies = [ + "cfg-if 1.0.0", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi 0.3.9", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1", +] + +[[package]] +name = "pin-project" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffbc8e94b38ea3d2d8ba92aea2983b503cd75d0888d75b86bb37970b5698e15" +dependencies = [ + "pin-project-internal 0.4.27", +] + +[[package]] +name = "pin-project" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ccc2237c2c489783abd8c4c80e5450fc0e98644555b1364da68cc29aa151ca7" +dependencies = [ + "pin-project-internal 1.0.2", +] + +[[package]] +name = "pin-project-internal" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ad2ae56b6abe3a1ee25f15ee605bacadb9a764edaba9c2bf4103800d4a1895" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8e8d2bf0b23038a4424865103a4df472855692821aab4e4f5c3312d461d9e5f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c917123afa01924fc84bb20c4c03f004d9c38e5127e3c039bbf7f4b9c76a2f6b" + +[[package]] +name = "pin-project-lite" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b063f57ec186e6140e2b8b6921e5f1bd89c7356dda5b33acc5401203ca6131c" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check 0.9.2", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check 0.9.2", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + +[[package]] +name = "proc-macro-nested" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a" + +[[package]] +name = "proc-macro2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi 0.3.9", +] + +[[package]] +name = "rand" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca" +dependencies = [ + "autocfg 0.1.7", + "libc", + "rand_chacha 0.1.1", + "rand_core 0.4.2", + "rand_hc 0.1.0", + "rand_isaac", + "rand_jitter", + "rand_os", + "rand_pcg", + "rand_xorshift", + "winapi 0.3.9", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc 0.2.0", +] + +[[package]] +name = "rand" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76330fb486679b4ace3670f117bbc9e16204005c4bde9c4bd372f45bed34f12" +dependencies = [ + "libc", + "rand_chacha 0.3.0", + "rand_core 0.6.0", + "rand_hc 0.3.0", +] + +[[package]] +name = "rand_chacha" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556d3a1ca6600bfcbab7c7c91ccb085ac7fbbcd70e008a98742e7847f4f7bcef" +dependencies = [ + "autocfg 0.1.7", + "rand_core 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.0", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8b34ba8cfb21243bd8df91854c830ff0d785fff2e82ebd4434c2644cb9ada18" +dependencies = [ + "getrandom 0.2.0", +] + +[[package]] +name = "rand_hc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b40677c7be09ae76218dc623efbf7b18e34bced3f38883af07bb75630a21bc4" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_hc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +dependencies = [ + "rand_core 0.6.0", +] + +[[package]] +name = "rand_isaac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded997c9d5f13925be2a6fd7e66bf1872597f759fd9dd93513dd7e92e5a5ee08" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_jitter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1166d5c91dc97b88d1decc3285bb0a99ed84b05cfd0bc2341bdf2d43fc41e39b" +dependencies = [ + "libc", + "rand_core 0.4.2", + "winapi 0.3.9", +] + +[[package]] +name = "rand_os" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b75f676a1e053fc562eafbb47838d67c84801e38fc1ba459e8f180deabd5071" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.4.2", + "rdrand", + "winapi 0.3.9", +] + +[[package]] +name = "rand_pcg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf9b09b01790cfe0364f52bf32995ea3c39f4d2dd011eac241d2914146d0b44" +dependencies = [ + "autocfg 0.1.7", + "rand_core 0.4.2", +] + +[[package]] +name = "rand_xorshift" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf7e9e623549b0e21f6e97cf8ecf247c1a8fd2e8a992ae265314300b2455d5c" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "regex" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38cf2c13ed4745de91a5eb834e11c00bcc3709e773173b2ce4c56c9fbde04b9c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", + "thread_local", +] + +[[package]] +name = "regex-syntax" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b181ba2dcf07aaccad5448e8ead58db5b742cf85dfe035e2227f137a539a189" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "ring" +version = "0.16.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "024a1e66fea74c66c66624ee5622a7ff0e4b73a13b4f5c326ddb50c708944226" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi 0.3.9", +] + +[[package]] +name = "rustls" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1126dcf58e93cee7d098dbda643b5f92ed724f1f6a63007c1116eed6700c81" +dependencies = [ + "base64 0.12.3", + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +dependencies = [ + "lazy_static", + "winapi 0.3.9", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sct" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3042af939fca8c3453b7af0f1c66e533a15a86169e39de2657310ade8f98d3c" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1759c2e3c8580017a484a7ac56d3abc5a6c1feadf88db2f3633f12ae4268c69" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f99b9d5e26d2a71633cc4f2ebae7cc9f874044e0c351a27e17892d76dce5678b" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", + "serde", +] + +[[package]] +name = "semver-parser" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e012c6c5380fb91897ba7b9261a0f565e624e869d42fe1a1d03fa0d68a083d5" +dependencies = [ + "pest", + "pest_derive", +] + +[[package]] +name = "serde" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fceb2595057b6891a4ee808f70054bd2d12f0e97f1cbb78689b59f676df325a" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", + "fake-simd", + "opaque-debug 0.2.3", +] + +[[package]] +name = "sha2" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7aab86fe2149bad8c507606bdb3f4ef5e7b2380eb92350f56122cca72a42a8" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if 1.0.0", + "cpuid-bool", + "digest 0.9.0", + "opaque-debug 0.3.0", +] + +[[package]] +name = "slab" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8" + +[[package]] +name = "slug" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bc762e6a4b6c6fcaade73e77f9ebc6991b676f88bb2358bddb56560f073373" +dependencies = [ + "deunicode", +] + +[[package]] +name = "smallvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a55ca5f3b68e41c979bf8c46a6f1da892ca4db8f94023ce0bd32407573b1ac0" + +[[package]] +name = "socket2" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "sqlformat" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c70f0235b9925cbb106c52af1a28b5ea4885a8b851e328b8562e257a389c2d" +dependencies = [ + "lazy_static", + "maplit", + "nom 6.0.1", + "regex", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1a98f9bf17b690f026b6fec565293a995b46dfbd6293debcb654dcffd2d1b34" +dependencies = [ + "sqlx-core", + "sqlx-macros", +] + +[[package]] +name = "sqlx-core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bb6a2ca3345a86493bc3b71eabc2c6c16a8bb1aa476cf5303bee27f67627d7" +dependencies = [ + "ahash 0.6.2", + "atoi", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-channel", + "crossbeam-queue", + "crossbeam-utils", + "either", + "futures-channel", + "futures-core", + "futures-util", + "hashlink", + "hex", + "itoa", + "libc", + "libsqlite3-sys", + "log", + "memchr", + "once_cell", + "parking_lot", + "percent-encoding", + "rustls", + "sha2", + "smallvec", + "sqlformat", + "sqlx-rt", + "stringprep", + "thiserror", + "url", + "webpki", + "webpki-roots", + "whoami", +] + +[[package]] +name = "sqlx-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5ada8b3b565331275ce913368565a273a74faf2a34da58c4dc010ce3286844" +dependencies = [ + "cargo_metadata", + "dotenv", + "either", + "futures", + "heck", + "lazy_static", + "proc-macro2", + "quote", + "sha2", + "sqlx-core", + "sqlx-rt", + "syn", + "url", +] + +[[package]] +name = "sqlx-rt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63fc5454c9dd7aaea3a0eeeb65ca40d06d0d8e7413a8184f7c3a3ffa5056190b" +dependencies = [ + "once_cell", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "structopt" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c" +dependencies = [ + "clap", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9802ddde94170d186eeee5005b798d9c159fa970403f1be19976d0cfb939b72" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "tap" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36474e732d1affd3a6ed582781b3683df3d0563714c59c39591e8ff707cf078e" + +[[package]] +name = "tempfile" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "rand 0.7.3", + "redox_syscall", + "remove_dir_all", + "winapi 0.3.9", +] + +[[package]] +name = "tera" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac6ab7eacf40937241959d540670f06209c38ceadb62116999db4a950fbf8dc" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.0", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cc616c6abf8c8928e2fdcc0dbfab37175edd8fb49a4641066ad1364fdab146" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be73a2caec27583d0046ef3796c3794f868a5bc813db689eed00c7631275cd1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi 0.3.9", +] + +[[package]] +name = "tinyvec" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf8dbc19eb42fba10e8feaaec282fb50e2c14b2726d6301dbfeed0f73306a6f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099837d3464c16a808060bb3f02263b412f6fafcb5d01c533d309985fbeebe48" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "iovec", + "lazy_static", + "libc", + "memchr", + "mio", + "mio-uds", + "num_cpus", + "pin-project-lite 0.1.11", + "slab", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e44da00bfc73a25f814cd8d7e57a68a5c31b74b3152a0a1d1f590c97ed06265a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12831b255bcfa39dc0436b01e19fea231a37db570686c06ee72c423479f889a" +dependencies = [ + "futures-core", + "rustls", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-util" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite 0.1.11", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e987b6bf443f4b5b3b6f38704195592cca41c5bb7aedd3c3693c7081f8289860" + +[[package]] +name = "tracing" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f47026cdc4080c07e49b37087de021820269d996f581aac150ef9e5583eefe3" +dependencies = [ + "cfg-if 1.0.0", + "log", + "pin-project-lite 0.2.0", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50de3927f93d202783f4513cda820ab47ef17f624b03c096e86ef00c67e6b5f" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "tracing-futures" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab7bb6f14721aa00656086e9335d363c5c8747bae02ebe32ea2c7dece5689b4c" +dependencies = [ + "pin-project 0.4.27", + "tracing", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "typenum" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" + +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13e63ab62dbe32aeee58d1c5408d35c36c392bba5d9d3142287219721afe606" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5909f2b0817350449ed73e8bcd81c8c3c8d9a7a5d8acba4b27db277f1868976e" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbc611eb48397705a6b0f6e917da23ae517e4d127123d2cf7674206627d32a" +dependencies = [ + "rand 0.6.5", +] + +[[package]] +name = "uuid" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fde2f6a4bea1d6e007c4ad38c6839fa71cbb63b6dbf5b595aa38dc9b1093c11" +dependencies = [ + "rand 0.7.3", +] + +[[package]] +name = "vcpkg" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" + +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + +[[package]] +name = "walkdir" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d" +dependencies = [ + "same-file", + "winapi 0.3.9", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasm-bindgen" +version = "0.2.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd364751395ca0f68cafb17666eee36b63077fb5ecd972bbcd74c90c4bf736e" +dependencies = [ + "cfg-if 1.0.0", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1114f89ab1f4106e5b55e688b828c0ab0ea593a1ea7c094b141b14cbaaec2d62" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6ac8995ead1f084a8dea1e65f194d0973800c7f571f6edd70adf06ecf77084" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a48c72f299d80557c7c62e37e7225369ecc0c963964059509fbafe917c7549" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7811dd7f9398f14cc76efd356f98f03aa30419dea46aa810d71e819fc97158" + +[[package]] +name = "web-sys" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222b1ef9334f92a21d3fb53dc3fd80f30836959a90f9274a626d7e06315ba3c3" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82015b7e0b8bad8185994674a13a93306bea76cf5a16c5a181382fd3a5ec2376" +dependencies = [ + "webpki", +] + +[[package]] +name = "whoami" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d595b2e146f36183d6a590b8d41568e2bc84c922267f43baf61c956330eeb436" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winutil" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7daf138b6b14196e3830a588acf1e86966c694d3e8fb026fb105b8b5dca07e6e" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "ws2_32-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3ae6867 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "budget" +version = "0.1.0" +authors = ["Joris "] +edition = "2018" + +[dependencies] +bcrypt = "0.9" +chrono = "0.4" +env_logger = "0.8" +hyper = "0.13" +lettre = "0.9" +lettre_email = "0.9" +log = "0.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_urlencoded = "0.7" +sha2 = "0.9" +sqlx = { version = "0.4", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } +sqlx-core = "0.4" +structopt = "0.3" +tera = { version = "1.6", features = ["builtins"] } +tokio = { version = "0.2", features = ["macros", "sync", "rt-threaded"] } +tokio-util = { version = "0.3", features = ["codec"] } +url = "2.2" +uuid = { version = "0.8", features = ["v4"] } diff --git a/Makefile b/Makefile deleted file mode 100644 index 46bbcc7..0000000 --- a/Makefile +++ /dev/null @@ -1,62 +0,0 @@ -start: - @nix-shell nix/tools.nix --command "tmuxinator local" - -stop: - @tmux kill-session -t sharedCost - -clean: clean-server clean-client - -build: build-server build-client cp-client - -# Client -# ------ - -clean-client: - @rm -rf dist-client - -build-client: - @nix-shell -A shells.ghcjs --run "make build-client-inside" - -build-client-inside: - @cabal --project-file=cabal-client.project --builddir=dist-client new-build all - -cp-client: - @cp dist-client/build/x86_64-linux/ghcjs-*/client-*/*/client/build/client/client.jsexe/all.js public/javascript/main.js - -watch-client: - @nix-shell -A shells.ghcjs --run "nodemon --delay 0.2 --watch client --watch common --ext hs --exec '(tput reset && make build-client-inside && make cp-client) || true'" - -# Server -# ------ - -clean-server: - @rm -rf dist-server - -build-server: - @nix-shell -A shells.ghc --run "make build-server-inside" - -build-server-inside: - @cabal --project-file=cabal-server.project --builddir=dist-server new-build all - -run-server: - @(fuser -k 3000/tcp &>/dev/null) || : - @./dist-server/build/x86_64-linux/ghc-*/server-0.0.1/*/server/build/server/server - -watch-server: - @nix-shell -A shells.ghc --run "nodemon --delay 0.2 --watch ./server --watch ./common --ext hs --exec '(tput reset && make build-server-inside && make run-server) || :'" - -# Deploy -# ------ - -deploy: - @make clean - @nix-build -o result-server -A ghc.server - @nix-build -o result-client -A ghcjs.client - @nix-shell -p closurecompiler --command 'closure-compiler result-client/bin/client.jsexe/all.js --js_output_file public/javascript/main.js' - @rm -rf bundle - @mkdir bundle - @cp application.conf bundle - @cp -r public bundle - @cp result-server/bin/server bundle - @rsync -avzhr bundle/ guyonvarch.me:servers/shared-cost - @rm -rf bundle diff --git a/README.md b/README.md index 8c736d4..b5c09a2 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,41 @@ -# Shared Cost +# Budget -Share costs with a group of people: +- pay according to your income, +- configure monthly payments, +- get statistics, +- get weekly activity by email. -- Share according to people income, -- Monthly payments available, -- Statistics by month, -- Weekly activity sent by email. +# Technologies -## Getting started +- database: Sqlite +- server: Rust with hyper, sqlx, +- templates: Tera, +- frontend: JavaScript, +- style: CSS. -Install nix: +# Screenshots -``` -curl https://nixos.org/nix/install | sh -``` +## Payments -Start the environment with: +![Payments](docs/payments.png) -```bash -./make start -``` +## Balance -Init the database with migration scripts: +![Balance](docs/balance.png) -```bash -sqlite3 database < server/migrations/1.sql -sqlite3 database < server/migrations/2.sql -sqlite3 database < server/migrations/3.sql -``` +## Statistics -Inside the tmux session, add some users with sqlite after the migration is done: +![Statistics](docs/statistics.png) -``` -sqlite3 database -insert into user(creation, email, name, password) values (datetime('now'), 'john@mail.com', 'John', '$2y$14$1QqyMA8vknmSVBq9BcGi6upZISLwsP2aPXx5JZOMPVzaZ8gorrsq.'); -insert into user(creation, email, name, password) values (datetime('now'), 'lisa@mail.com', 'Lisa', '$2y$14$1QqyMA8vknmSVBq9BcGi6upZISLwsP2aPXx5JZOMPVzaZ8gorrsq.'); -``` +# Getting started -Later, stop the environment with: +1. Use `nix-shell` to download dependencies. -```bash -./make stop -``` +2. Initialize the database with `bin/db init`. -## Deploy +3. Start the application with `bin/watch run`. -```bash -make deploy -``` +4. Connect with either: -## Configuration - -See [application.conf](application.conf). - -## Documentation - -- [reflex](https://hackage.haskell.org/package/reflex-0.6.2.4/docs/doc-index-All.html) -- [reflex-dom](https://hackage.haskell.org/package/reflex-dom-core-0.5/docs/doc-index-All.html) +- `john@mail.com` / `password` +- or `lisa@mail.com` / `password`. diff --git a/application.conf b/application.conf deleted file mode 100644 index 021fa2a..0000000 --- a/application.conf +++ /dev/null @@ -1,9 +0,0 @@ -hostname = "localhost:3000" -port = 3000 -currency = "€" -signInExpiration = 5 minutes -noReplyMail = "no-reply@mail.com" -https = False -devMode = True - -importMaybe "local.conf" diff --git a/assets/chart.js b/assets/chart.js new file mode 100644 index 0000000..7134d26 --- /dev/null +++ b/assets/chart.js @@ -0,0 +1,7 @@ +/*! + * Chart.js v2.9.4 + * https://www.chartjs.org + * (c) 2020 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Chart=e()}(this,(function(){"use strict";"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self&&self;function t(){throw new Error("Dynamic requires are not currently supported by rollup-plugin-commonjs")}function e(t,e){return t(e={exports:{}},e.exports),e.exports}var n={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},i=e((function(t){var e={};for(var i in n)n.hasOwnProperty(i)&&(e[n[i]]=i);var a=t.exports={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};for(var r in a)if(a.hasOwnProperty(r)){if(!("channels"in a[r]))throw new Error("missing channels property: "+r);if(!("labels"in a[r]))throw new Error("missing channel labels property: "+r);if(a[r].labels.length!==a[r].channels)throw new Error("channel and label counts mismatch: "+r);var o=a[r].channels,s=a[r].labels;delete a[r].channels,delete a[r].labels,Object.defineProperty(a[r],"channels",{value:o}),Object.defineProperty(a[r],"labels",{value:s})}a.rgb.hsl=function(t){var e,n,i=t[0]/255,a=t[1]/255,r=t[2]/255,o=Math.min(i,a,r),s=Math.max(i,a,r),l=s-o;return s===o?e=0:i===s?e=(a-r)/l:a===s?e=2+(r-i)/l:r===s&&(e=4+(i-a)/l),(e=Math.min(60*e,360))<0&&(e+=360),n=(o+s)/2,[e,100*(s===o?0:n<=.5?l/(s+o):l/(2-s-o)),100*n]},a.rgb.hsv=function(t){var e,n,i,a,r,o=t[0]/255,s=t[1]/255,l=t[2]/255,u=Math.max(o,s,l),d=u-Math.min(o,s,l),h=function(t){return(u-t)/6/d+.5};return 0===d?a=r=0:(r=d/u,e=h(o),n=h(s),i=h(l),o===u?a=i-n:s===u?a=1/3+e-i:l===u&&(a=2/3+n-e),a<0?a+=1:a>1&&(a-=1)),[360*a,100*r,100*u]},a.rgb.hwb=function(t){var e=t[0],n=t[1],i=t[2];return[a.rgb.hsl(t)[0],100*(1/255*Math.min(e,Math.min(n,i))),100*(i=1-1/255*Math.max(e,Math.max(n,i)))]},a.rgb.cmyk=function(t){var e,n=t[0]/255,i=t[1]/255,a=t[2]/255;return[100*((1-n-(e=Math.min(1-n,1-i,1-a)))/(1-e)||0),100*((1-i-e)/(1-e)||0),100*((1-a-e)/(1-e)||0),100*e]},a.rgb.keyword=function(t){var i=e[t];if(i)return i;var a,r,o,s=1/0;for(var l in n)if(n.hasOwnProperty(l)){var u=n[l],d=(r=t,o=u,Math.pow(r[0]-o[0],2)+Math.pow(r[1]-o[1],2)+Math.pow(r[2]-o[2],2));d.04045?Math.pow((e+.055)/1.055,2.4):e/12.92)+.3576*(n=n>.04045?Math.pow((n+.055)/1.055,2.4):n/12.92)+.1805*(i=i>.04045?Math.pow((i+.055)/1.055,2.4):i/12.92)),100*(.2126*e+.7152*n+.0722*i),100*(.0193*e+.1192*n+.9505*i)]},a.rgb.lab=function(t){var e=a.rgb.xyz(t),n=e[0],i=e[1],r=e[2];return i/=100,r/=108.883,n=(n/=95.047)>.008856?Math.pow(n,1/3):7.787*n+16/116,[116*(i=i>.008856?Math.pow(i,1/3):7.787*i+16/116)-16,500*(n-i),200*(i-(r=r>.008856?Math.pow(r,1/3):7.787*r+16/116))]},a.hsl.rgb=function(t){var e,n,i,a,r,o=t[0]/360,s=t[1]/100,l=t[2]/100;if(0===s)return[r=255*l,r,r];e=2*l-(n=l<.5?l*(1+s):l+s-l*s),a=[0,0,0];for(var u=0;u<3;u++)(i=o+1/3*-(u-1))<0&&i++,i>1&&i--,r=6*i<1?e+6*(n-e)*i:2*i<1?n:3*i<2?e+(n-e)*(2/3-i)*6:e,a[u]=255*r;return a},a.hsl.hsv=function(t){var e=t[0],n=t[1]/100,i=t[2]/100,a=n,r=Math.max(i,.01);return n*=(i*=2)<=1?i:2-i,a*=r<=1?r:2-r,[e,100*(0===i?2*a/(r+a):2*n/(i+n)),100*((i+n)/2)]},a.hsv.rgb=function(t){var e=t[0]/60,n=t[1]/100,i=t[2]/100,a=Math.floor(e)%6,r=e-Math.floor(e),o=255*i*(1-n),s=255*i*(1-n*r),l=255*i*(1-n*(1-r));switch(i*=255,a){case 0:return[i,l,o];case 1:return[s,i,o];case 2:return[o,i,l];case 3:return[o,s,i];case 4:return[l,o,i];case 5:return[i,o,s]}},a.hsv.hsl=function(t){var e,n,i,a=t[0],r=t[1]/100,o=t[2]/100,s=Math.max(o,.01);return i=(2-r)*o,n=r*s,[a,100*(n=(n/=(e=(2-r)*s)<=1?e:2-e)||0),100*(i/=2)]},a.hwb.rgb=function(t){var e,n,i,a,r,o,s,l=t[0]/360,u=t[1]/100,d=t[2]/100,h=u+d;switch(h>1&&(u/=h,d/=h),i=6*l-(e=Math.floor(6*l)),0!=(1&e)&&(i=1-i),a=u+i*((n=1-d)-u),e){default:case 6:case 0:r=n,o=a,s=u;break;case 1:r=a,o=n,s=u;break;case 2:r=u,o=n,s=a;break;case 3:r=u,o=a,s=n;break;case 4:r=a,o=u,s=n;break;case 5:r=n,o=u,s=a}return[255*r,255*o,255*s]},a.cmyk.rgb=function(t){var e=t[0]/100,n=t[1]/100,i=t[2]/100,a=t[3]/100;return[255*(1-Math.min(1,e*(1-a)+a)),255*(1-Math.min(1,n*(1-a)+a)),255*(1-Math.min(1,i*(1-a)+a))]},a.xyz.rgb=function(t){var e,n,i,a=t[0]/100,r=t[1]/100,o=t[2]/100;return n=-.9689*a+1.8758*r+.0415*o,i=.0557*a+-.204*r+1.057*o,e=(e=3.2406*a+-1.5372*r+-.4986*o)>.0031308?1.055*Math.pow(e,1/2.4)-.055:12.92*e,n=n>.0031308?1.055*Math.pow(n,1/2.4)-.055:12.92*n,i=i>.0031308?1.055*Math.pow(i,1/2.4)-.055:12.92*i,[255*(e=Math.min(Math.max(0,e),1)),255*(n=Math.min(Math.max(0,n),1)),255*(i=Math.min(Math.max(0,i),1))]},a.xyz.lab=function(t){var e=t[0],n=t[1],i=t[2];return n/=100,i/=108.883,e=(e/=95.047)>.008856?Math.pow(e,1/3):7.787*e+16/116,[116*(n=n>.008856?Math.pow(n,1/3):7.787*n+16/116)-16,500*(e-n),200*(n-(i=i>.008856?Math.pow(i,1/3):7.787*i+16/116))]},a.lab.xyz=function(t){var e,n,i,a=t[0];e=t[1]/500+(n=(a+16)/116),i=n-t[2]/200;var r=Math.pow(n,3),o=Math.pow(e,3),s=Math.pow(i,3);return n=r>.008856?r:(n-16/116)/7.787,e=o>.008856?o:(e-16/116)/7.787,i=s>.008856?s:(i-16/116)/7.787,[e*=95.047,n*=100,i*=108.883]},a.lab.lch=function(t){var e,n=t[0],i=t[1],a=t[2];return(e=360*Math.atan2(a,i)/2/Math.PI)<0&&(e+=360),[n,Math.sqrt(i*i+a*a),e]},a.lch.lab=function(t){var e,n=t[0],i=t[1];return e=t[2]/360*2*Math.PI,[n,i*Math.cos(e),i*Math.sin(e)]},a.rgb.ansi16=function(t){var e=t[0],n=t[1],i=t[2],r=1 in arguments?arguments[1]:a.rgb.hsv(t)[2];if(0===(r=Math.round(r/50)))return 30;var o=30+(Math.round(i/255)<<2|Math.round(n/255)<<1|Math.round(e/255));return 2===r&&(o+=60),o},a.hsv.ansi16=function(t){return a.rgb.ansi16(a.hsv.rgb(t),t[2])},a.rgb.ansi256=function(t){var e=t[0],n=t[1],i=t[2];return e===n&&n===i?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(n/255*5)+Math.round(i/255*5)},a.ansi16.rgb=function(t){var e=t%10;if(0===e||7===e)return t>50&&(e+=3.5),[e=e/10.5*255,e,e];var n=.5*(1+~~(t>50));return[(1&e)*n*255,(e>>1&1)*n*255,(e>>2&1)*n*255]},a.ansi256.rgb=function(t){if(t>=232){var e=10*(t-232)+8;return[e,e,e]}var n;return t-=16,[Math.floor(t/36)/5*255,Math.floor((n=t%36)/6)/5*255,n%6/5*255]},a.rgb.hex=function(t){var e=(((255&Math.round(t[0]))<<16)+((255&Math.round(t[1]))<<8)+(255&Math.round(t[2]))).toString(16).toUpperCase();return"000000".substring(e.length)+e},a.hex.rgb=function(t){var e=t.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!e)return[0,0,0];var n=e[0];3===e[0].length&&(n=n.split("").map((function(t){return t+t})).join(""));var i=parseInt(n,16);return[i>>16&255,i>>8&255,255&i]},a.rgb.hcg=function(t){var e,n=t[0]/255,i=t[1]/255,a=t[2]/255,r=Math.max(Math.max(n,i),a),o=Math.min(Math.min(n,i),a),s=r-o;return e=s<=0?0:r===n?(i-a)/s%6:r===i?2+(a-n)/s:4+(n-i)/s+4,e/=6,[360*(e%=1),100*s,100*(s<1?o/(1-s):0)]},a.hsl.hcg=function(t){var e=t[1]/100,n=t[2]/100,i=1,a=0;return(i=n<.5?2*e*n:2*e*(1-n))<1&&(a=(n-.5*i)/(1-i)),[t[0],100*i,100*a]},a.hsv.hcg=function(t){var e=t[1]/100,n=t[2]/100,i=e*n,a=0;return i<1&&(a=(n-i)/(1-i)),[t[0],100*i,100*a]},a.hcg.rgb=function(t){var e=t[0]/360,n=t[1]/100,i=t[2]/100;if(0===n)return[255*i,255*i,255*i];var a,r=[0,0,0],o=e%1*6,s=o%1,l=1-s;switch(Math.floor(o)){case 0:r[0]=1,r[1]=s,r[2]=0;break;case 1:r[0]=l,r[1]=1,r[2]=0;break;case 2:r[0]=0,r[1]=1,r[2]=s;break;case 3:r[0]=0,r[1]=l,r[2]=1;break;case 4:r[0]=s,r[1]=0,r[2]=1;break;default:r[0]=1,r[1]=0,r[2]=l}return a=(1-n)*i,[255*(n*r[0]+a),255*(n*r[1]+a),255*(n*r[2]+a)]},a.hcg.hsv=function(t){var e=t[1]/100,n=e+t[2]/100*(1-e),i=0;return n>0&&(i=e/n),[t[0],100*i,100*n]},a.hcg.hsl=function(t){var e=t[1]/100,n=t[2]/100*(1-e)+.5*e,i=0;return n>0&&n<.5?i=e/(2*n):n>=.5&&n<1&&(i=e/(2*(1-n))),[t[0],100*i,100*n]},a.hcg.hwb=function(t){var e=t[1]/100,n=e+t[2]/100*(1-e);return[t[0],100*(n-e),100*(1-n)]},a.hwb.hcg=function(t){var e=t[1]/100,n=1-t[2]/100,i=n-e,a=0;return i<1&&(a=(n-i)/(1-i)),[t[0],100*i,100*a]},a.apple.rgb=function(t){return[t[0]/65535*255,t[1]/65535*255,t[2]/65535*255]},a.rgb.apple=function(t){return[t[0]/255*65535,t[1]/255*65535,t[2]/255*65535]},a.gray.rgb=function(t){return[t[0]/100*255,t[0]/100*255,t[0]/100*255]},a.gray.hsl=a.gray.hsv=function(t){return[0,0,t[0]]},a.gray.hwb=function(t){return[0,100,t[0]]},a.gray.cmyk=function(t){return[0,0,0,t[0]]},a.gray.lab=function(t){return[t[0],0,0]},a.gray.hex=function(t){var e=255&Math.round(t[0]/100*255),n=((e<<16)+(e<<8)+e).toString(16).toUpperCase();return"000000".substring(n.length)+n},a.rgb.gray=function(t){return[(t[0]+t[1]+t[2])/3/255*100]}}));i.rgb,i.hsl,i.hsv,i.hwb,i.cmyk,i.xyz,i.lab,i.lch,i.hex,i.keyword,i.ansi16,i.ansi256,i.hcg,i.apple,i.gray;function a(t){var e=function(){for(var t={},e=Object.keys(i),n=e.length,a=0;a1&&(e=Array.prototype.slice.call(arguments));var n=t(e);if("object"==typeof n)for(var i=n.length,a=0;a1&&(e=Array.prototype.slice.call(arguments)),t(e))};return"conversion"in t&&(e.conversion=t.conversion),e}(i)}))}));var l=s,u={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},d={getRgba:h,getHsla:c,getRgb:function(t){var e=h(t);return e&&e.slice(0,3)},getHsl:function(t){var e=c(t);return e&&e.slice(0,3)},getHwb:f,getAlpha:function(t){var e=h(t);if(e)return e[3];if(e=c(t))return e[3];if(e=f(t))return e[3]},hexString:function(t,e){e=void 0!==e&&3===t.length?e:t[3];return"#"+b(t[0])+b(t[1])+b(t[2])+(e>=0&&e<1?b(Math.round(255*e)):"")},rgbString:function(t,e){if(e<1||t[3]&&t[3]<1)return g(t,e);return"rgb("+t[0]+", "+t[1]+", "+t[2]+")"},rgbaString:g,percentString:function(t,e){if(e<1||t[3]&&t[3]<1)return m(t,e);var n=Math.round(t[0]/255*100),i=Math.round(t[1]/255*100),a=Math.round(t[2]/255*100);return"rgb("+n+"%, "+i+"%, "+a+"%)"},percentaString:m,hslString:function(t,e){if(e<1||t[3]&&t[3]<1)return p(t,e);return"hsl("+t[0]+", "+t[1]+"%, "+t[2]+"%)"},hslaString:p,hwbString:function(t,e){void 0===e&&(e=void 0!==t[3]?t[3]:1);return"hwb("+t[0]+", "+t[1]+"%, "+t[2]+"%"+(void 0!==e&&1!==e?", "+e:"")+")"},keyword:function(t){return y[t.slice(0,3)]}};function h(t){if(t){var e=[0,0,0],n=1,i=t.match(/^#([a-fA-F0-9]{3,4})$/i),a="";if(i){a=(i=i[1])[3];for(var r=0;rn?(e+.05)/(n+.05):(n+.05)/(e+.05)},level:function(t){var e=this.contrast(t);return e>=7.1?"AAA":e>=4.5?"AA":""},dark:function(){var t=this.values.rgb;return(299*t[0]+587*t[1]+114*t[2])/1e3<128},light:function(){return!this.dark()},negate:function(){for(var t=[],e=0;e<3;e++)t[e]=255-this.values.rgb[e];return this.setValues("rgb",t),this},lighten:function(t){var e=this.values.hsl;return e[2]+=e[2]*t,this.setValues("hsl",e),this},darken:function(t){var e=this.values.hsl;return e[2]-=e[2]*t,this.setValues("hsl",e),this},saturate:function(t){var e=this.values.hsl;return e[1]+=e[1]*t,this.setValues("hsl",e),this},desaturate:function(t){var e=this.values.hsl;return e[1]-=e[1]*t,this.setValues("hsl",e),this},whiten:function(t){var e=this.values.hwb;return e[1]+=e[1]*t,this.setValues("hwb",e),this},blacken:function(t){var e=this.values.hwb;return e[2]+=e[2]*t,this.setValues("hwb",e),this},greyscale:function(){var t=this.values.rgb,e=.3*t[0]+.59*t[1]+.11*t[2];return this.setValues("rgb",[e,e,e]),this},clearer:function(t){var e=this.values.alpha;return this.setValues("alpha",e-e*t),this},opaquer:function(t){var e=this.values.alpha;return this.setValues("alpha",e+e*t),this},rotate:function(t){var e=this.values.hsl,n=(e[0]+t)%360;return e[0]=n<0?360+n:n,this.setValues("hsl",e),this},mix:function(t,e){var n=t,i=void 0===e?.5:e,a=2*i-1,r=this.alpha()-n.alpha(),o=((a*r==-1?a:(a+r)/(1+a*r))+1)/2,s=1-o;return this.rgb(o*this.red()+s*n.red(),o*this.green()+s*n.green(),o*this.blue()+s*n.blue()).alpha(this.alpha()*i+n.alpha()*(1-i))},toJSON:function(){return this.rgb()},clone:function(){var t,e,n=new _,i=this.values,a=n.values;for(var r in i)i.hasOwnProperty(r)&&(t=i[r],"[object Array]"===(e={}.toString.call(t))?a[r]=t.slice(0):"[object Number]"===e?a[r]=t:console.error("unexpected color value:",t));return n}},_.prototype.spaces={rgb:["red","green","blue"],hsl:["hue","saturation","lightness"],hsv:["hue","saturation","value"],hwb:["hue","whiteness","blackness"],cmyk:["cyan","magenta","yellow","black"]},_.prototype.maxes={rgb:[255,255,255],hsl:[360,100,100],hsv:[360,100,100],hwb:[360,100,100],cmyk:[100,100,100,100]},_.prototype.getValues=function(t){for(var e=this.values,n={},i=0;i=0;a--)e.call(n,t[a],a);else for(a=0;a=1?t:-(Math.sqrt(1-t*t)-1)},easeOutCirc:function(t){return Math.sqrt(1-(t-=1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:1===t?1:(n||(n=.3),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),-i*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n))},easeOutElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:1===t?1:(n||(n=.3),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),i*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/n)+1)},easeInOutElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:2==(t/=.5)?1:(n||(n=.45),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),t<1?i*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*-.5:i*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*.5+1)},easeInBack:function(t){var e=1.70158;return t*t*((e+1)*t-e)},easeOutBack:function(t){var e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack:function(t){var e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:function(t){return 1-C.easeOutBounce(1-t)},easeOutBounce:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:function(t){return t<.5?.5*C.easeInBounce(2*t):.5*C.easeOutBounce(2*t-1)+.5}},P={effects:C};D.easingEffects=C;var T=Math.PI,O=T/180,A=2*T,F=T/2,I=T/4,L=2*T/3,R={clear:function(t){t.ctx.clearRect(0,0,t.width,t.height)},roundedRect:function(t,e,n,i,a,r){if(r){var o=Math.min(r,a/2,i/2),s=e+o,l=n+o,u=e+i-o,d=n+a-o;t.moveTo(e,l),se.left-1e-6&&t.xe.top-1e-6&&t.y0&&this.requestAnimationFrame()},advance:function(){for(var t,e,n,i,a=this.animations,r=0;r=n?(B.callback(t.onAnimationComplete,[t],e),e.animating=!1,a.splice(r,1)):++r}},tt=B.options.resolve,et=["push","pop","shift","splice","unshift"];function nt(t,e){var n=t._chartjs;if(n){var i=n.listeners,a=i.indexOf(e);-1!==a&&i.splice(a,1),i.length>0||(et.forEach((function(e){delete t[e]})),delete t._chartjs)}}var it=function(t,e){this.initialize(t,e)};B.extend(it.prototype,{datasetElementType:null,dataElementType:null,_datasetElementOptions:["backgroundColor","borderCapStyle","borderColor","borderDash","borderDashOffset","borderJoinStyle","borderWidth"],_dataElementOptions:["backgroundColor","borderColor","borderWidth","pointStyle"],initialize:function(t,e){var n=this;n.chart=t,n.index=e,n.linkScales(),n.addElements(),n._type=n.getMeta().type},updateIndex:function(t){this.index=t},linkScales:function(){var t=this.getMeta(),e=this.chart,n=e.scales,i=this.getDataset(),a=e.options.scales;null!==t.xAxisID&&t.xAxisID in n&&!i.xAxisID||(t.xAxisID=i.xAxisID||a.xAxes[0].id),null!==t.yAxisID&&t.yAxisID in n&&!i.yAxisID||(t.yAxisID=i.yAxisID||a.yAxes[0].id)},getDataset:function(){return this.chart.data.datasets[this.index]},getMeta:function(){return this.chart.getDatasetMeta(this.index)},getScaleForId:function(t){return this.chart.scales[t]},_getValueScaleId:function(){return this.getMeta().yAxisID},_getIndexScaleId:function(){return this.getMeta().xAxisID},_getValueScale:function(){return this.getScaleForId(this._getValueScaleId())},_getIndexScale:function(){return this.getScaleForId(this._getIndexScaleId())},reset:function(){this._update(!0)},destroy:function(){this._data&&nt(this._data,this)},createMetaDataset:function(){var t=this.datasetElementType;return t&&new t({_chart:this.chart,_datasetIndex:this.index})},createMetaData:function(t){var e=this.dataElementType;return e&&new e({_chart:this.chart,_datasetIndex:this.index,_index:t})},addElements:function(){var t,e,n=this.getMeta(),i=this.getDataset().data||[],a=n.data;for(t=0,e=i.length;tn&&this.insertElements(n,i-n)},insertElements:function(t,e){for(var n=0;na?(r=a/e.innerRadius,t.arc(o,s,e.innerRadius-a,i+r,n-r,!0)):t.arc(o,s,a,i+Math.PI/2,n-Math.PI/2),t.closePath(),t.clip()}function st(t,e,n){var i="inner"===e.borderAlign;i?(t.lineWidth=2*e.borderWidth,t.lineJoin="round"):(t.lineWidth=e.borderWidth,t.lineJoin="bevel"),n.fullCircles&&function(t,e,n,i){var a,r=n.endAngle;for(i&&(n.endAngle=n.startAngle+rt,ot(t,n),n.endAngle=r,n.endAngle===n.startAngle&&n.fullCircles&&(n.endAngle+=rt,n.fullCircles--)),t.beginPath(),t.arc(n.x,n.y,n.innerRadius,n.startAngle+rt,n.startAngle,!0),a=0;as;)a-=rt;for(;a=o&&a<=s,u=r>=n.innerRadius&&r<=n.outerRadius;return l&&u}return!1},getCenterPoint:function(){var t=this._view,e=(t.startAngle+t.endAngle)/2,n=(t.innerRadius+t.outerRadius)/2;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},getArea:function(){var t=this._view;return Math.PI*((t.endAngle-t.startAngle)/(2*Math.PI))*(Math.pow(t.outerRadius,2)-Math.pow(t.innerRadius,2))},tooltipPosition:function(){var t=this._view,e=t.startAngle+(t.endAngle-t.startAngle)/2,n=(t.outerRadius-t.innerRadius)/2+t.innerRadius;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},draw:function(){var t,e=this._chart.ctx,n=this._view,i="inner"===n.borderAlign?.33:0,a={x:n.x,y:n.y,innerRadius:n.innerRadius,outerRadius:Math.max(n.outerRadius-i,0),pixelMargin:i,startAngle:n.startAngle,endAngle:n.endAngle,fullCircles:Math.floor(n.circumference/rt)};if(e.save(),e.fillStyle=n.backgroundColor,e.strokeStyle=n.borderColor,a.fullCircles){for(a.endAngle=a.startAngle+rt,e.beginPath(),e.arc(a.x,a.y,a.outerRadius,a.startAngle,a.endAngle),e.arc(a.x,a.y,a.innerRadius,a.endAngle,a.startAngle,!0),e.closePath(),t=0;tt.x&&(e=yt(e,"left","right")):t.basen?n:i,r:l.right||a<0?0:a>e?e:a,b:l.bottom||r<0?0:r>n?n:r,l:l.left||o<0?0:o>e?e:o}}function _t(t,e,n){var i=null===e,a=null===n,r=!(!t||i&&a)&&bt(t);return r&&(i||e>=r.left&&e<=r.right)&&(a||n>=r.top&&n<=r.bottom)}Y._set("global",{elements:{rectangle:{backgroundColor:pt,borderColor:pt,borderSkipped:"bottom",borderWidth:0}}});var wt=X.extend({_type:"rectangle",draw:function(){var t=this._chart.ctx,e=this._view,n=function(t){var e=bt(t),n=e.right-e.left,i=e.bottom-e.top,a=xt(t,n/2,i/2);return{outer:{x:e.left,y:e.top,w:n,h:i},inner:{x:e.left+a.l,y:e.top+a.t,w:n-a.l-a.r,h:i-a.t-a.b}}}(e),i=n.outer,a=n.inner;t.fillStyle=e.backgroundColor,t.fillRect(i.x,i.y,i.w,i.h),i.w===a.w&&i.h===a.h||(t.save(),t.beginPath(),t.rect(i.x,i.y,i.w,i.h),t.clip(),t.fillStyle=e.borderColor,t.rect(a.x,a.y,a.w,a.h),t.fill("evenodd"),t.restore())},height:function(){var t=this._view;return t.base-t.y},inRange:function(t,e){return _t(this._view,t,e)},inLabelRange:function(t,e){var n=this._view;return vt(n)?_t(n,t,null):_t(n,null,e)},inXRange:function(t){return _t(this._view,t,null)},inYRange:function(t){return _t(this._view,null,t)},getCenterPoint:function(){var t,e,n=this._view;return vt(n)?(t=n.x,e=(n.y+n.base)/2):(t=(n.x+n.base)/2,e=n.y),{x:t,y:e}},getArea:function(){var t=this._view;return vt(t)?t.width*Math.abs(t.y-t.base):t.height*Math.abs(t.x-t.base)},tooltipPosition:function(){var t=this._view;return{x:t.x,y:t.y}}}),kt={},Mt=lt,St=ht,Dt=mt,Ct=wt;kt.Arc=Mt,kt.Line=St,kt.Point=Dt,kt.Rectangle=Ct;var Pt=B._deprecated,Tt=B.valueOrDefault;function Ot(t,e,n){var i,a,r=n.barThickness,o=e.stackCount,s=e.pixels[t],l=B.isNullOrUndef(r)?function(t,e){var n,i,a,r,o=t._length;for(a=1,r=e.length;a0?Math.min(o,Math.abs(i-n)):o,n=i;return o}(e.scale,e.pixels):-1;return B.isNullOrUndef(r)?(i=l*n.categoryPercentage,a=n.barPercentage):(i=r*o,a=1),{chunk:i/o,ratio:a,start:s-i/2}}Y._set("bar",{hover:{mode:"label"},scales:{xAxes:[{type:"category",offset:!0,gridLines:{offsetGridLines:!0}}],yAxes:[{type:"linear"}]}}),Y._set("global",{datasets:{bar:{categoryPercentage:.8,barPercentage:.9}}});var At=at.extend({dataElementType:kt.Rectangle,_dataElementOptions:["backgroundColor","borderColor","borderSkipped","borderWidth","barPercentage","barThickness","categoryPercentage","maxBarThickness","minBarLength"],initialize:function(){var t,e,n=this;at.prototype.initialize.apply(n,arguments),(t=n.getMeta()).stack=n.getDataset().stack,t.bar=!0,e=n._getIndexScale().options,Pt("bar chart",e.barPercentage,"scales.[x/y]Axes.barPercentage","dataset.barPercentage"),Pt("bar chart",e.barThickness,"scales.[x/y]Axes.barThickness","dataset.barThickness"),Pt("bar chart",e.categoryPercentage,"scales.[x/y]Axes.categoryPercentage","dataset.categoryPercentage"),Pt("bar chart",n._getValueScale().options.minBarLength,"scales.[x/y]Axes.minBarLength","dataset.minBarLength"),Pt("bar chart",e.maxBarThickness,"scales.[x/y]Axes.maxBarThickness","dataset.maxBarThickness")},update:function(t){var e,n,i=this.getMeta().data;for(this._ruler=this.getRuler(),e=0,n=i.length;e=0&&m.min>=0?m.min:m.max,x=void 0===m.start?m.end:m.max>=0&&m.min>=0?m.max-m.min:m.min-m.max,_=g.length;if(v||void 0===v&&void 0!==b)for(i=0;i<_&&(a=g[i]).index!==t;++i)a.stack===b&&(r=void 0===(u=h._parseValue(f[a.index].data[e])).start?u.end:u.min>=0&&u.max>=0?u.max:u.min,(m.min<0&&r<0||m.max>=0&&r>0)&&(y+=r));return o=h.getPixelForValue(y),l=(s=h.getPixelForValue(y+x))-o,void 0!==p&&Math.abs(l)=0&&!c||x<0&&c?o-p:o+p),{size:l,base:o,head:s,center:s+l/2}},calculateBarIndexPixels:function(t,e,n,i){var a="flex"===i.barThickness?function(t,e,n){var i,a=e.pixels,r=a[t],o=t>0?a[t-1]:null,s=t=Nt?-Wt:b<-Nt?Wt:0)+p,x=Math.cos(b),_=Math.sin(b),w=Math.cos(y),k=Math.sin(y),M=b<=0&&y>=0||y>=Wt,S=b<=Yt&&y>=Yt||y>=Wt+Yt,D=b<=-Yt&&y>=-Yt||y>=Nt+Yt,C=b===-Nt||y>=Nt?-1:Math.min(x,x*m,w,w*m),P=D?-1:Math.min(_,_*m,k,k*m),T=M?1:Math.max(x,x*m,w,w*m),O=S?1:Math.max(_,_*m,k,k*m);u=(T-C)/2,d=(O-P)/2,h=-(T+C)/2,c=-(O+P)/2}for(i=0,a=g.length;i0&&!isNaN(t)?Wt*(Math.abs(t)/e):0},getMaxBorderWidth:function(t){var e,n,i,a,r,o,s,l,u=0,d=this.chart;if(!t)for(e=0,n=d.data.datasets.length;e(u=s>u?s:u)?l:u);return u},setHoverStyle:function(t){var e=t._model,n=t._options,i=B.getHoverColor;t.$previousStyle={backgroundColor:e.backgroundColor,borderColor:e.borderColor,borderWidth:e.borderWidth},e.backgroundColor=Rt(n.hoverBackgroundColor,i(n.backgroundColor)),e.borderColor=Rt(n.hoverBorderColor,i(n.borderColor)),e.borderWidth=Rt(n.hoverBorderWidth,n.borderWidth)},_getRingWeightOffset:function(t){for(var e=0,n=0;n0&&Bt(l[t-1]._model,s)&&(n.controlPointPreviousX=u(n.controlPointPreviousX,s.left,s.right),n.controlPointPreviousY=u(n.controlPointPreviousY,s.top,s.bottom)),t0&&(r=t.getDatasetMeta(r[0]._datasetIndex).data),r},"x-axis":function(t,e){return re(t,e,{intersect:!1})},point:function(t,e){return ne(t,te(e,t))},nearest:function(t,e,n){var i=te(e,t);n.axis=n.axis||"xy";var a=ae(n.axis);return ie(t,i,n.intersect,a)},x:function(t,e,n){var i=te(e,t),a=[],r=!1;return ee(t,(function(t){t.inXRange(i.x)&&a.push(t),t.inRange(i.x,i.y)&&(r=!0)})),n.intersect&&!r&&(a=[]),a},y:function(t,e,n){var i=te(e,t),a=[],r=!1;return ee(t,(function(t){t.inYRange(i.y)&&a.push(t),t.inRange(i.x,i.y)&&(r=!0)})),n.intersect&&!r&&(a=[]),a}}},se=B.extend;function le(t,e){return B.where(t,(function(t){return t.pos===e}))}function ue(t,e){return t.sort((function(t,n){var i=e?n:t,a=e?t:n;return i.weight===a.weight?i.index-a.index:i.weight-a.weight}))}function de(t,e,n,i){return Math.max(t[n],e[n])+Math.max(t[i],e[i])}function he(t,e,n){var i,a,r=n.box,o=t.maxPadding;if(n.size&&(t[n.pos]-=n.size),n.size=n.horizontal?r.height:r.width,t[n.pos]+=n.size,r.getPadding){var s=r.getPadding();o.top=Math.max(o.top,s.top),o.left=Math.max(o.left,s.left),o.bottom=Math.max(o.bottom,s.bottom),o.right=Math.max(o.right,s.right)}if(i=e.outerWidth-de(o,t,"left","right"),a=e.outerHeight-de(o,t,"top","bottom"),i!==t.w||a!==t.h){t.w=i,t.h=a;var l=n.horizontal?[i,t.w]:[a,t.h];return!(l[0]===l[1]||isNaN(l[0])&&isNaN(l[1]))}}function ce(t,e){var n=e.maxPadding;function i(t){var i={left:0,top:0,right:0,bottom:0};return t.forEach((function(t){i[t]=Math.max(e[t],n[t])})),i}return i(t?["left","right"]:["top","bottom"])}function fe(t,e,n){var i,a,r,o,s,l,u=[];for(i=0,a=t.length;idiv{position:absolute;width:1000000px;height:1000000px;left:0;top:0}.chartjs-size-monitor-shrink>div{position:absolute;width:200%;height:200%;left:0;top:0}"}))&&me.default||me,be="$chartjs",ye="chartjs-size-monitor",xe="chartjs-render-monitor",_e="chartjs-render-animation",we=["animationstart","webkitAnimationStart"],ke={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"};function Me(t,e){var n=B.getStyle(t,e),i=n&&n.match(/^(\d+)(\.\d+)?px$/);return i?Number(i[1]):void 0}var Se=!!function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("e",null,e)}catch(t){}return t}()&&{passive:!0};function De(t,e,n){t.addEventListener(e,n,Se)}function Ce(t,e,n){t.removeEventListener(e,n,Se)}function Pe(t,e,n,i,a){return{type:t,chart:e,native:a||null,x:void 0!==n?n:null,y:void 0!==i?i:null}}function Te(t){var e=document.createElement("div");return e.className=t||"",e}function Oe(t,e,n){var i,a,r,o,s=t[be]||(t[be]={}),l=s.resizer=function(t){var e=Te(ye),n=Te(ye+"-expand"),i=Te(ye+"-shrink");n.appendChild(Te()),i.appendChild(Te()),e.appendChild(n),e.appendChild(i),e._reset=function(){n.scrollLeft=1e6,n.scrollTop=1e6,i.scrollLeft=1e6,i.scrollTop=1e6};var a=function(){e._reset(),t()};return De(n,"scroll",a.bind(n,"expand")),De(i,"scroll",a.bind(i,"shrink")),e}((i=function(){if(s.resizer){var i=n.options.maintainAspectRatio&&t.parentNode,a=i?i.clientWidth:0;e(Pe("resize",n)),i&&i.clientWidth0){var r=t[0];r.label?n=r.label:r.xLabel?n=r.xLabel:a>0&&r.index-1?t.split("\n"):t}function He(t){var e=Y.global;return{xPadding:t.xPadding,yPadding:t.yPadding,xAlign:t.xAlign,yAlign:t.yAlign,rtl:t.rtl,textDirection:t.textDirection,bodyFontColor:t.bodyFontColor,_bodyFontFamily:We(t.bodyFontFamily,e.defaultFontFamily),_bodyFontStyle:We(t.bodyFontStyle,e.defaultFontStyle),_bodyAlign:t.bodyAlign,bodyFontSize:We(t.bodyFontSize,e.defaultFontSize),bodySpacing:t.bodySpacing,titleFontColor:t.titleFontColor,_titleFontFamily:We(t.titleFontFamily,e.defaultFontFamily),_titleFontStyle:We(t.titleFontStyle,e.defaultFontStyle),titleFontSize:We(t.titleFontSize,e.defaultFontSize),_titleAlign:t.titleAlign,titleSpacing:t.titleSpacing,titleMarginBottom:t.titleMarginBottom,footerFontColor:t.footerFontColor,_footerFontFamily:We(t.footerFontFamily,e.defaultFontFamily),_footerFontStyle:We(t.footerFontStyle,e.defaultFontStyle),footerFontSize:We(t.footerFontSize,e.defaultFontSize),_footerAlign:t.footerAlign,footerSpacing:t.footerSpacing,footerMarginTop:t.footerMarginTop,caretSize:t.caretSize,cornerRadius:t.cornerRadius,backgroundColor:t.backgroundColor,opacity:0,legendColorBackground:t.multiKeyBackground,displayColors:t.displayColors,borderColor:t.borderColor,borderWidth:t.borderWidth}}function Be(t,e){return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-t.xPadding:t.x+t.xPadding}function je(t){return Ee([],Ve(t))}var Ue=X.extend({initialize:function(){this._model=He(this._options),this._lastActive=[]},getTitle:function(){var t=this,e=t._options,n=e.callbacks,i=n.beforeTitle.apply(t,arguments),a=n.title.apply(t,arguments),r=n.afterTitle.apply(t,arguments),o=[];return o=Ee(o,Ve(i)),o=Ee(o,Ve(a)),o=Ee(o,Ve(r))},getBeforeBody:function(){return je(this._options.callbacks.beforeBody.apply(this,arguments))},getBody:function(t,e){var n=this,i=n._options.callbacks,a=[];return B.each(t,(function(t){var r={before:[],lines:[],after:[]};Ee(r.before,Ve(i.beforeLabel.call(n,t,e))),Ee(r.lines,i.label.call(n,t,e)),Ee(r.after,Ve(i.afterLabel.call(n,t,e))),a.push(r)})),a},getAfterBody:function(){return je(this._options.callbacks.afterBody.apply(this,arguments))},getFooter:function(){var t=this,e=t._options.callbacks,n=e.beforeFooter.apply(t,arguments),i=e.footer.apply(t,arguments),a=e.afterFooter.apply(t,arguments),r=[];return r=Ee(r,Ve(n)),r=Ee(r,Ve(i)),r=Ee(r,Ve(a))},update:function(t){var e,n,i,a,r,o,s,l,u,d,h=this,c=h._options,f=h._model,g=h._model=He(c),m=h._active,p=h._data,v={xAlign:f.xAlign,yAlign:f.yAlign},b={x:f.x,y:f.y},y={width:f.width,height:f.height},x={x:f.caretX,y:f.caretY};if(m.length){g.opacity=1;var _=[],w=[];x=ze[c.position].call(h,m,h._eventPosition);var k=[];for(e=0,n=m.length;ei.width&&(a=i.width-e.width),a<0&&(a=0)),"top"===d?r+=h:r-="bottom"===d?e.height+h:e.height/2,"center"===d?"left"===u?a+=h:"right"===u&&(a-=h):"left"===u?a-=c:"right"===u&&(a+=c),{x:a,y:r}}(g,y,v=function(t,e){var n,i,a,r,o,s=t._model,l=t._chart,u=t._chart.chartArea,d="center",h="center";s.yl.height-e.height&&(h="bottom");var c=(u.left+u.right)/2,f=(u.top+u.bottom)/2;"center"===h?(n=function(t){return t<=c},i=function(t){return t>c}):(n=function(t){return t<=e.width/2},i=function(t){return t>=l.width-e.width/2}),a=function(t){return t+e.width+s.caretSize+s.caretPadding>l.width},r=function(t){return t-e.width-s.caretSize-s.caretPadding<0},o=function(t){return t<=f?"top":"bottom"},n(s.x)?(d="left",a(s.x)&&(d="center",h=o(s.y))):i(s.x)&&(d="right",r(s.x)&&(d="center",h=o(s.y)));var g=t._options;return{xAlign:g.xAlign?g.xAlign:d,yAlign:g.yAlign?g.yAlign:h}}(this,y),h._chart)}else g.opacity=0;return g.xAlign=v.xAlign,g.yAlign=v.yAlign,g.x=b.x,g.y=b.y,g.width=y.width,g.height=y.height,g.caretX=x.x,g.caretY=x.y,h._model=g,t&&c.custom&&c.custom.call(h,g),h},drawCaret:function(t,e){var n=this._chart.ctx,i=this._view,a=this.getCaretPosition(t,e,i);n.lineTo(a.x1,a.y1),n.lineTo(a.x2,a.y2),n.lineTo(a.x3,a.y3)},getCaretPosition:function(t,e,n){var i,a,r,o,s,l,u=n.caretSize,d=n.cornerRadius,h=n.xAlign,c=n.yAlign,f=t.x,g=t.y,m=e.width,p=e.height;if("center"===c)s=g+p/2,"left"===h?(a=(i=f)-u,r=i,o=s+u,l=s-u):(a=(i=f+m)+u,r=i,o=s-u,l=s+u);else if("left"===h?(i=(a=f+d+u)-u,r=a+u):"right"===h?(i=(a=f+m-d-u)-u,r=a+u):(i=(a=n.caretX)-u,r=a+u),"top"===c)s=(o=g)-u,l=o;else{s=(o=g+p)+u,l=o;var v=r;r=i,i=v}return{x1:i,x2:a,x3:r,y1:o,y2:s,y3:l}},drawTitle:function(t,e,n){var i,a,r,o=e.title,s=o.length;if(s){var l=Ye(e.rtl,e.x,e.width);for(t.x=Be(e,e._titleAlign),n.textAlign=l.textAlign(e._titleAlign),n.textBaseline="middle",i=e.titleFontSize,a=e.titleSpacing,n.fillStyle=e.titleFontColor,n.font=B.fontString(i,e._titleFontStyle,e._titleFontFamily),r=0;r0&&n.stroke()},draw:function(){var t=this._chart.ctx,e=this._view;if(0!==e.opacity){var n={width:e.width,height:e.height},i={x:e.x,y:e.y},a=Math.abs(e.opacity<.001)?0:e.opacity,r=e.title.length||e.beforeBody.length||e.body.length||e.afterBody.length||e.footer.length;this._options.enabled&&r&&(t.save(),t.globalAlpha=a,this.drawBackground(i,e,t,n),i.y+=e.yPadding,B.rtl.overrideTextDirection(t,e.textDirection),this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),B.rtl.restoreTextDirection(t,e.textDirection),t.restore())}},handleEvent:function(t){var e,n=this,i=n._options;return n._lastActive=n._lastActive||[],"mouseout"===t.type?n._active=[]:(n._active=n._chart.getElementsAtEventForMode(t,i.mode,i),i.reverse&&n._active.reverse()),(e=!B.arrayEquals(n._active,n._lastActive))&&(n._lastActive=n._active,(i.enabled||i.custom)&&(n._eventPosition={x:t.x,y:t.y},n.update(!0),n.pivot())),e}}),Ge=ze,qe=Ue;qe.positioners=Ge;var Ze=B.valueOrDefault;function $e(){return B.merge(Object.create(null),[].slice.call(arguments),{merger:function(t,e,n,i){if("xAxes"===t||"yAxes"===t){var a,r,o,s=n[t].length;for(e[t]||(e[t]=[]),a=0;a=e[t].length&&e[t].push({}),!e[t][a].type||o.type&&o.type!==e[t][a].type?B.merge(e[t][a],[Ne.getScaleDefaults(r),o]):B.merge(e[t][a],o)}else B._merger(t,e,n,i)}})}function Xe(){return B.merge(Object.create(null),[].slice.call(arguments),{merger:function(t,e,n,i){var a=e[t]||Object.create(null),r=n[t];"scales"===t?e[t]=$e(a,r):"scale"===t?e[t]=B.merge(a,[Ne.getScaleDefaults(r.type),r]):B._merger(t,e,n,i)}})}function Ke(t){var e=t.options;B.each(t.scales,(function(e){pe.removeBox(t,e)})),e=Xe(Y.global,Y[t.config.type],e),t.options=t.config.options=e,t.ensureScalesHaveIDs(),t.buildOrUpdateScales(),t.tooltip._options=e.tooltips,t.tooltip.initialize()}function Je(t,e,n){var i,a=function(t){return t.id===i};do{i=e+n++}while(B.findIndex(t,a)>=0);return i}function Qe(t){return"top"===t||"bottom"===t}function tn(t,e){return function(n,i){return n[t]===i[t]?n[e]-i[e]:n[t]-i[t]}}Y._set("global",{elements:{},events:["mousemove","mouseout","click","touchstart","touchmove"],hover:{onHover:null,mode:"nearest",intersect:!0,animationDuration:400},onClick:null,maintainAspectRatio:!0,responsive:!0,responsiveAnimationDuration:0});var en=function(t,e){return this.construct(t,e),this};B.extend(en.prototype,{construct:function(t,e){var n=this;e=function(t){var e=(t=t||Object.create(null)).data=t.data||{};return e.datasets=e.datasets||[],e.labels=e.labels||[],t.options=Xe(Y.global,Y[t.type],t.options||{}),t}(e);var i=Le.acquireContext(t,e),a=i&&i.canvas,r=a&&a.height,o=a&&a.width;n.id=B.uid(),n.ctx=i,n.canvas=a,n.config=e,n.width=o,n.height=r,n.aspectRatio=r?o/r:null,n.options=e.options,n._bufferedRender=!1,n._layers=[],n.chart=n,n.controller=n,en.instances[n.id]=n,Object.defineProperty(n,"data",{get:function(){return n.config.data},set:function(t){n.config.data=t}}),i&&a?(n.initialize(),n.update()):console.error("Failed to create chart: can't acquire context from the given item")},initialize:function(){var t=this;return Re.notify(t,"beforeInit"),B.retinaScale(t,t.options.devicePixelRatio),t.bindEvents(),t.options.responsive&&t.resize(!0),t.initToolTip(),Re.notify(t,"afterInit"),t},clear:function(){return B.canvas.clear(this),this},stop:function(){return Q.cancelAnimation(this),this},resize:function(t){var e=this,n=e.options,i=e.canvas,a=n.maintainAspectRatio&&e.aspectRatio||null,r=Math.max(0,Math.floor(B.getMaximumWidth(i))),o=Math.max(0,Math.floor(a?r/a:B.getMaximumHeight(i)));if((e.width!==r||e.height!==o)&&(i.width=e.width=r,i.height=e.height=o,i.style.width=r+"px",i.style.height=o+"px",B.retinaScale(e,n.devicePixelRatio),!t)){var s={width:r,height:o};Re.notify(e,"resize",[s]),n.onResize&&n.onResize(e,s),e.stop(),e.update({duration:n.responsiveAnimationDuration})}},ensureScalesHaveIDs:function(){var t=this.options,e=t.scales||{},n=t.scale;B.each(e.xAxes,(function(t,n){t.id||(t.id=Je(e.xAxes,"x-axis-",n))})),B.each(e.yAxes,(function(t,n){t.id||(t.id=Je(e.yAxes,"y-axis-",n))})),n&&(n.id=n.id||"scale")},buildOrUpdateScales:function(){var t=this,e=t.options,n=t.scales||{},i=[],a=Object.keys(n).reduce((function(t,e){return t[e]=!1,t}),{});e.scales&&(i=i.concat((e.scales.xAxes||[]).map((function(t){return{options:t,dtype:"category",dposition:"bottom"}})),(e.scales.yAxes||[]).map((function(t){return{options:t,dtype:"linear",dposition:"left"}})))),e.scale&&i.push({options:e.scale,dtype:"radialLinear",isDefault:!0,dposition:"chartArea"}),B.each(i,(function(e){var i=e.options,r=i.id,o=Ze(i.type,e.dtype);Qe(i.position)!==Qe(e.dposition)&&(i.position=e.dposition),a[r]=!0;var s=null;if(r in n&&n[r].type===o)(s=n[r]).options=i,s.ctx=t.ctx,s.chart=t;else{var l=Ne.getScaleConstructor(o);if(!l)return;s=new l({id:r,type:o,options:i,ctx:t.ctx,chart:t}),n[s.id]=s}s.mergeTicksOptions(),e.isDefault&&(t.scale=s)})),B.each(a,(function(t,e){t||delete n[e]})),t.scales=n,Ne.addScalesToLayout(this)},buildOrUpdateControllers:function(){var t,e,n=this,i=[],a=n.data.datasets;for(t=0,e=a.length;t=0;--n)this.drawDataset(e[n],t);Re.notify(this,"afterDatasetsDraw",[t])}},drawDataset:function(t,e){var n={meta:t,index:t.index,easingValue:e};!1!==Re.notify(this,"beforeDatasetDraw",[n])&&(t.controller.draw(e),Re.notify(this,"afterDatasetDraw",[n]))},_drawTooltip:function(t){var e=this.tooltip,n={tooltip:e,easingValue:t};!1!==Re.notify(this,"beforeTooltipDraw",[n])&&(e.draw(),Re.notify(this,"afterTooltipDraw",[n]))},getElementAtEvent:function(t){return oe.modes.single(this,t)},getElementsAtEvent:function(t){return oe.modes.label(this,t,{intersect:!0})},getElementsAtXAxis:function(t){return oe.modes["x-axis"](this,t,{intersect:!0})},getElementsAtEventForMode:function(t,e,n){var i=oe.modes[e];return"function"==typeof i?i(this,t,n):[]},getDatasetAtEvent:function(t){return oe.modes.dataset(this,t,{intersect:!0})},getDatasetMeta:function(t){var e=this.data.datasets[t];e._meta||(e._meta={});var n=e._meta[this.id];return n||(n=e._meta[this.id]={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e.order||0,index:t}),n},getVisibleDatasetCount:function(){for(var t=0,e=0,n=this.data.datasets.length;e3?n[2]-n[1]:n[1]-n[0];Math.abs(i)>1&&t!==Math.floor(t)&&(i=t-Math.floor(t));var a=B.log10(Math.abs(i)),r="";if(0!==t)if(Math.max(Math.abs(n[0]),Math.abs(n[n.length-1]))<1e-4){var o=B.log10(Math.abs(t)),s=Math.floor(o)-Math.floor(a);s=Math.max(Math.min(s,20),0),r=t.toExponential(s)}else{var l=-1*Math.floor(a);l=Math.max(Math.min(l,20),0),r=t.toFixed(l)}else r="0";return r},logarithmic:function(t,e,n){var i=t/Math.pow(10,Math.floor(B.log10(t)));return 0===t?"0":1===i||2===i||5===i||0===e||e===n.length-1?t.toExponential():""}}},ln=B.isArray,un=B.isNullOrUndef,dn=B.valueOrDefault,hn=B.valueAtIndexOrDefault;function cn(t,e,n){var i,a=t.getTicks().length,r=Math.min(e,a-1),o=t.getPixelForTick(r),s=t._startPixel,l=t._endPixel;if(!(n&&(i=1===a?Math.max(o-s,l-o):0===e?(t.getPixelForTick(1)-o)/2:(o-t.getPixelForTick(r-1))/2,(o+=rl+1e-6)))return o}function fn(t,e,n,i){var a,r,o,s,l,u,d,h,c,f,g,m,p,v=n.length,b=[],y=[],x=[],_=0,w=0;for(a=0;ae){for(n=0;n=c||d<=1||!s.isHorizontal()?s.labelRotation=h:(e=(t=s._getLabelSizes()).widest.width,n=t.highest.height-t.highest.offset,i=Math.min(s.maxWidth,s.chart.width-e),e+6>(a=l.offset?s.maxWidth/d:i/(d-1))&&(a=i/(d-(l.offset?.5:1)),r=s.maxHeight-gn(l.gridLines)-u.padding-mn(l.scaleLabel),o=Math.sqrt(e*e+n*n),f=B.toDegrees(Math.min(Math.asin(Math.min((t.highest.height+6)/a,1)),Math.asin(Math.min(r/o,1))-Math.asin(n/o))),f=Math.max(h,Math.min(c,f))),s.labelRotation=f)},afterCalculateTickRotation:function(){B.callback(this.options.afterCalculateTickRotation,[this])},beforeFit:function(){B.callback(this.options.beforeFit,[this])},fit:function(){var t=this,e=t.minSize={width:0,height:0},n=t.chart,i=t.options,a=i.ticks,r=i.scaleLabel,o=i.gridLines,s=t._isVisible(),l="bottom"===i.position,u=t.isHorizontal();if(u?e.width=t.maxWidth:s&&(e.width=gn(o)+mn(r)),u?s&&(e.height=gn(o)+mn(r)):e.height=t.maxHeight,a.display&&s){var d=vn(a),h=t._getLabelSizes(),c=h.first,f=h.last,g=h.widest,m=h.highest,p=.4*d.minor.lineHeight,v=a.padding;if(u){var b=0!==t.labelRotation,y=B.toRadians(t.labelRotation),x=Math.cos(y),_=Math.sin(y),w=_*g.width+x*(m.height-(b?m.offset:0))+(b?0:p);e.height=Math.min(t.maxHeight,e.height+w+v);var k,M,S=t.getPixelForTick(0)-t.left,D=t.right-t.getPixelForTick(t.getTicks().length-1);b?(k=l?x*c.width+_*c.offset:_*(c.height-c.offset),M=l?_*(f.height-f.offset):x*f.width+_*f.offset):(k=c.width/2,M=f.width/2),t.paddingLeft=Math.max((k-S)*t.width/(t.width-S),0)+3,t.paddingRight=Math.max((M-D)*t.width/(t.width-D),0)+3}else{var C=a.mirror?0:g.width+v+p;e.width=Math.min(t.maxWidth,e.width+C),t.paddingTop=c.height/2,t.paddingBottom=f.height/2}}t.handleMargins(),u?(t.width=t._length=n.width-t.margins.left-t.margins.right,t.height=e.height):(t.width=e.width,t.height=t._length=n.height-t.margins.top-t.margins.bottom)},handleMargins:function(){var t=this;t.margins&&(t.margins.left=Math.max(t.paddingLeft,t.margins.left),t.margins.top=Math.max(t.paddingTop,t.margins.top),t.margins.right=Math.max(t.paddingRight,t.margins.right),t.margins.bottom=Math.max(t.paddingBottom,t.margins.bottom))},afterFit:function(){B.callback(this.options.afterFit,[this])},isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},isFullWidth:function(){return this.options.fullWidth},getRightValue:function(t){if(un(t))return NaN;if(("number"==typeof t||t instanceof Number)&&!isFinite(t))return NaN;if(t)if(this.isHorizontal()){if(void 0!==t.x)return this.getRightValue(t.x)}else if(void 0!==t.y)return this.getRightValue(t.y);return t},_convertTicksToLabels:function(t){var e,n,i,a=this;for(a.ticks=t.map((function(t){return t.value})),a.beforeTickToLabelConversion(),e=a.convertTicksToLabels(t)||a.ticks,a.afterTickToLabelConversion(),n=0,i=t.length;nn-1?null:this.getPixelForDecimal(t*i+(e?i/2:0))},getPixelForDecimal:function(t){return this._reversePixels&&(t=1-t),this._startPixel+t*this._length},getDecimalForPixel:function(t){var e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e},getBasePixel:function(){return this.getPixelForValue(this.getBaseValue())},getBaseValue:function(){var t=this.min,e=this.max;return this.beginAtZero?0:t<0&&e<0?e:t>0&&e>0?t:0},_autoSkip:function(t){var e,n,i,a,r=this.options.ticks,o=this._length,s=r.maxTicksLimit||o/this._tickSize()+1,l=r.major.enabled?function(t){var e,n,i=[];for(e=0,n=t.length;es)return function(t,e,n){var i,a,r=0,o=e[0];for(n=Math.ceil(n),i=0;iu)return r;return Math.max(u,1)}(l,t,0,s),u>0){for(e=0,n=u-1;e1?(h-d)/(u-1):null,yn(t,i,B.isNullOrUndef(a)?0:d-a,d),yn(t,i,h,B.isNullOrUndef(a)?t.length:h+a),bn(t)}return yn(t,i),bn(t)},_tickSize:function(){var t=this.options.ticks,e=B.toRadians(this.labelRotation),n=Math.abs(Math.cos(e)),i=Math.abs(Math.sin(e)),a=this._getLabelSizes(),r=t.autoSkipPadding||0,o=a?a.widest.width+r:0,s=a?a.highest.height+r:0;return this.isHorizontal()?s*n>o*i?o/n:s/i:s*i=0&&(o=t),void 0!==r&&(t=n.indexOf(r))>=0&&(s=t),e.minIndex=o,e.maxIndex=s,e.min=n[o],e.max=n[s]},buildTicks:function(){var t=this._getLabels(),e=this.minIndex,n=this.maxIndex;this.ticks=0===e&&n===t.length-1?t:t.slice(e,n+1)},getLabelForIndex:function(t,e){var n=this.chart;return n.getDatasetMeta(e).controller._getValueScaleId()===this.id?this.getRightValue(n.data.datasets[e].data[t]):this._getLabels()[t]},_configure:function(){var t=this,e=t.options.offset,n=t.ticks;_n.prototype._configure.call(t),t.isHorizontal()||(t._reversePixels=!t._reversePixels),n&&(t._startValue=t.minIndex-(e?.5:0),t._valueRange=Math.max(n.length-(e?0:1),1))},getPixelForValue:function(t,e,n){var i,a,r,o=this;return wn(e)||wn(n)||(t=o.chart.data.datasets[n].data[e]),wn(t)||(i=o.isHorizontal()?t.x:t.y),(void 0!==i||void 0!==t&&isNaN(e))&&(a=o._getLabels(),t=B.valueOrDefault(i,t),e=-1!==(r=a.indexOf(t))?r:e,isNaN(e)&&(e=t)),o.getPixelForDecimal((e-o._startValue)/o._valueRange)},getPixelForTick:function(t){var e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t],t+this.minIndex)},getValueForPixel:function(t){var e=Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange);return Math.min(Math.max(e,0),this.ticks.length-1)},getBasePixel:function(){return this.bottom}}),Mn={position:"bottom"};kn._defaults=Mn;var Sn=B.noop,Dn=B.isNullOrUndef;var Cn=_n.extend({getRightValue:function(t){return"string"==typeof t?+t:_n.prototype.getRightValue.call(this,t)},handleTickRangeOptions:function(){var t=this,e=t.options.ticks;if(e.beginAtZero){var n=B.sign(t.min),i=B.sign(t.max);n<0&&i<0?t.max=0:n>0&&i>0&&(t.min=0)}var a=void 0!==e.min||void 0!==e.suggestedMin,r=void 0!==e.max||void 0!==e.suggestedMax;void 0!==e.min?t.min=e.min:void 0!==e.suggestedMin&&(null===t.min?t.min=e.suggestedMin:t.min=Math.min(t.min,e.suggestedMin)),void 0!==e.max?t.max=e.max:void 0!==e.suggestedMax&&(null===t.max?t.max=e.suggestedMax:t.max=Math.max(t.max,e.suggestedMax)),a!==r&&t.min>=t.max&&(a?t.max=t.min+1:t.min=t.max-1),t.min===t.max&&(t.max++,e.beginAtZero||t.min--)},getTickLimit:function(){var t,e=this.options.ticks,n=e.stepSize,i=e.maxTicksLimit;return n?t=Math.ceil(this.max/n)-Math.floor(this.min/n)+1:(t=this._computeTickLimit(),i=i||11),i&&(t=Math.min(i,t)),t},_computeTickLimit:function(){return Number.POSITIVE_INFINITY},handleDirectionalChanges:Sn,buildTicks:function(){var t=this,e=t.options.ticks,n=t.getTickLimit(),i={maxTicks:n=Math.max(2,n),min:e.min,max:e.max,precision:e.precision,stepSize:B.valueOrDefault(e.fixedStepSize,e.stepSize)},a=t.ticks=function(t,e){var n,i,a,r,o=[],s=t.stepSize,l=s||1,u=t.maxTicks-1,d=t.min,h=t.max,c=t.precision,f=e.min,g=e.max,m=B.niceNum((g-f)/u/l)*l;if(m<1e-14&&Dn(d)&&Dn(h))return[f,g];(r=Math.ceil(g/m)-Math.floor(f/m))>u&&(m=B.niceNum(r*m/u/l)*l),s||Dn(c)?n=Math.pow(10,B._decimalPlaces(m)):(n=Math.pow(10,c),m=Math.ceil(m*n)/n),i=Math.floor(f/m)*m,a=Math.ceil(g/m)*m,s&&(!Dn(d)&&B.almostWhole(d/m,m/1e3)&&(i=d),!Dn(h)&&B.almostWhole(h/m,m/1e3)&&(a=h)),r=(a-i)/m,r=B.almostEquals(r,Math.round(r),m/1e3)?Math.round(r):Math.ceil(r),i=Math.round(i*n)/n,a=Math.round(a*n)/n,o.push(Dn(d)?i:d);for(var p=1;pe.length-1?null:this.getPixelForValue(e[t])}}),Fn=Pn;An._defaults=Fn;var In=B.valueOrDefault,Ln=B.math.log10;var Rn={position:"left",ticks:{callback:sn.formatters.logarithmic}};function Nn(t,e){return B.isFinite(t)&&t>=0?t:e}var Wn=_n.extend({determineDataLimits:function(){var t,e,n,i,a,r,o=this,s=o.options,l=o.chart,u=l.data.datasets,d=o.isHorizontal();function h(t){return d?t.xAxisID===o.id:t.yAxisID===o.id}o.min=Number.POSITIVE_INFINITY,o.max=Number.NEGATIVE_INFINITY,o.minNotZero=Number.POSITIVE_INFINITY;var c=s.stacked;if(void 0===c)for(t=0;t0){var e=B.min(t),n=B.max(t);o.min=Math.min(o.min,e),o.max=Math.max(o.max,n)}}))}else for(t=0;t0?t.minNotZero=t.min:t.max<1?t.minNotZero=Math.pow(10,Math.floor(Ln(t.max))):t.minNotZero=1)},buildTicks:function(){var t=this,e=t.options.ticks,n=!t.isHorizontal(),i={min:Nn(e.min),max:Nn(e.max)},a=t.ticks=function(t,e){var n,i,a=[],r=In(t.min,Math.pow(10,Math.floor(Ln(e.min)))),o=Math.floor(Ln(e.max)),s=Math.ceil(e.max/Math.pow(10,o));0===r?(n=Math.floor(Ln(e.minNotZero)),i=Math.floor(e.minNotZero/Math.pow(10,n)),a.push(r),r=i*Math.pow(10,n)):(n=Math.floor(Ln(r)),i=Math.floor(r/Math.pow(10,n)));var l=n<0?Math.pow(10,Math.abs(n)):1;do{a.push(r),10===++i&&(i=1,l=++n>=0?1:l),r=Math.round(i*Math.pow(10,n)*l)/l}while(ne.length-1?null:this.getPixelForValue(e[t])},_getFirstTickValue:function(t){var e=Math.floor(Ln(t));return Math.floor(t/Math.pow(10,e))*Math.pow(10,e)},_configure:function(){var t=this,e=t.min,n=0;_n.prototype._configure.call(t),0===e&&(e=t._getFirstTickValue(t.minNotZero),n=In(t.options.ticks.fontSize,Y.global.defaultFontSize)/t._length),t._startValue=Ln(e),t._valueOffset=n,t._valueRange=(Ln(t.max)-Ln(e))/(1-n)},getPixelForValue:function(t){var e=this,n=0;return(t=+e.getRightValue(t))>e.min&&t>0&&(n=(Ln(t)-e._startValue)/e._valueRange+e._valueOffset),e.getPixelForDecimal(n)},getValueForPixel:function(t){var e=this,n=e.getDecimalForPixel(t);return 0===n&&0===e.min?0:Math.pow(10,e._startValue+(n-e._valueOffset)*e._valueRange)}}),Yn=Rn;Wn._defaults=Yn;var zn=B.valueOrDefault,En=B.valueAtIndexOrDefault,Vn=B.options.resolve,Hn={display:!0,animate:!0,position:"chartArea",angleLines:{display:!0,color:"rgba(0,0,0,0.1)",lineWidth:1,borderDash:[],borderDashOffset:0},gridLines:{circular:!1},ticks:{showLabelBackdrop:!0,backdropColor:"rgba(255,255,255,0.75)",backdropPaddingY:2,backdropPaddingX:2,callback:sn.formatters.linear},pointLabels:{display:!0,fontSize:10,callback:function(t){return t}}};function Bn(t){var e=t.ticks;return e.display&&t.display?zn(e.fontSize,Y.global.defaultFontSize)+2*e.backdropPaddingY:0}function jn(t,e,n,i,a){return t===i||t===a?{start:e-n/2,end:e+n/2}:ta?{start:e-n,end:e}:{start:e,end:e+n}}function Un(t){return 0===t||180===t?"center":t<180?"left":"right"}function Gn(t,e,n,i){var a,r,o=n.y+i/2;if(B.isArray(e))for(a=0,r=e.length;a270||t<90)&&(n.y-=e.h)}function Zn(t){return B.isNumber(t)?t:0}var $n=Cn.extend({setDimensions:function(){var t=this;t.width=t.maxWidth,t.height=t.maxHeight,t.paddingTop=Bn(t.options)/2,t.xCenter=Math.floor(t.width/2),t.yCenter=Math.floor((t.height-t.paddingTop)/2),t.drawingArea=Math.min(t.height-t.paddingTop,t.width)/2},determineDataLimits:function(){var t=this,e=t.chart,n=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;B.each(e.data.datasets,(function(a,r){if(e.isDatasetVisible(r)){var o=e.getDatasetMeta(r);B.each(a.data,(function(e,a){var r=+t.getRightValue(e);isNaN(r)||o.data[a].hidden||(n=Math.min(r,n),i=Math.max(r,i))}))}})),t.min=n===Number.POSITIVE_INFINITY?0:n,t.max=i===Number.NEGATIVE_INFINITY?0:i,t.handleTickRangeOptions()},_computeTickLimit:function(){return Math.ceil(this.drawingArea/Bn(this.options))},convertTicksToLabels:function(){var t=this;Cn.prototype.convertTicksToLabels.call(t),t.pointLabels=t.chart.data.labels.map((function(){var e=B.callback(t.options.pointLabels.callback,arguments,t);return e||0===e?e:""}))},getLabelForIndex:function(t,e){return+this.getRightValue(this.chart.data.datasets[e].data[t])},fit:function(){var t=this.options;t.display&&t.pointLabels.display?function(t){var e,n,i,a=B.options._parseFont(t.options.pointLabels),r={l:0,r:t.width,t:0,b:t.height-t.paddingTop},o={};t.ctx.font=a.string,t._pointLabelSizes=[];var s,l,u,d=t.chart.data.labels.length;for(e=0;er.r&&(r.r=f.end,o.r=h),g.startr.b&&(r.b=g.end,o.b=h)}t.setReductions(t.drawingArea,r,o)}(this):this.setCenterPoint(0,0,0,0)},setReductions:function(t,e,n){var i=this,a=e.l/Math.sin(n.l),r=Math.max(e.r-i.width,0)/Math.sin(n.r),o=-e.t/Math.cos(n.t),s=-Math.max(e.b-(i.height-i.paddingTop),0)/Math.cos(n.b);a=Zn(a),r=Zn(r),o=Zn(o),s=Zn(s),i.drawingArea=Math.min(Math.floor(t-(a+r)/2),Math.floor(t-(o+s)/2)),i.setCenterPoint(a,r,o,s)},setCenterPoint:function(t,e,n,i){var a=this,r=a.width-e-a.drawingArea,o=t+a.drawingArea,s=n+a.drawingArea,l=a.height-a.paddingTop-i-a.drawingArea;a.xCenter=Math.floor((o+r)/2+a.left),a.yCenter=Math.floor((s+l)/2+a.top+a.paddingTop)},getIndexAngle:function(t){var e=this.chart,n=(t*(360/e.data.labels.length)+((e.options||{}).startAngle||0))%360;return(n<0?n+360:n)*Math.PI*2/360},getDistanceFromCenterForValue:function(t){var e=this;if(B.isNullOrUndef(t))return NaN;var n=e.drawingArea/(e.max-e.min);return e.options.ticks.reverse?(e.max-t)*n:(t-e.min)*n},getPointPosition:function(t,e){var n=this.getIndexAngle(t)-Math.PI/2;return{x:Math.cos(n)*e+this.xCenter,y:Math.sin(n)*e+this.yCenter}},getPointPositionForValue:function(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))},getBasePosition:function(t){var e=this.min,n=this.max;return this.getPointPositionForValue(t||0,this.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0)},_drawGrid:function(){var t,e,n,i=this,a=i.ctx,r=i.options,o=r.gridLines,s=r.angleLines,l=zn(s.lineWidth,o.lineWidth),u=zn(s.color,o.color);if(r.pointLabels.display&&function(t){var e=t.ctx,n=t.options,i=n.pointLabels,a=Bn(n),r=t.getDistanceFromCenterForValue(n.ticks.reverse?t.min:t.max),o=B.options._parseFont(i);e.save(),e.font=o.string,e.textBaseline="middle";for(var s=t.chart.data.labels.length-1;s>=0;s--){var l=0===s?a/2:0,u=t.getPointPosition(s,r+l+5),d=En(i.fontColor,s,Y.global.defaultFontColor);e.fillStyle=d;var h=t.getIndexAngle(s),c=B.toDegrees(h);e.textAlign=Un(c),qn(c,t._pointLabelSizes[s],u),Gn(e,t.pointLabels[s],u,o.lineHeight)}e.restore()}(i),o.display&&B.each(i.ticks,(function(t,n){0!==n&&(e=i.getDistanceFromCenterForValue(i.ticksAsNumbers[n]),function(t,e,n,i){var a,r=t.ctx,o=e.circular,s=t.chart.data.labels.length,l=En(e.color,i-1),u=En(e.lineWidth,i-1);if((o||s)&&l&&u){if(r.save(),r.strokeStyle=l,r.lineWidth=u,r.setLineDash&&(r.setLineDash(e.borderDash||[]),r.lineDashOffset=e.borderDashOffset||0),r.beginPath(),o)r.arc(t.xCenter,t.yCenter,n,0,2*Math.PI);else{a=t.getPointPosition(0,n),r.moveTo(a.x,a.y);for(var d=1;d=0;t--)e=i.getDistanceFromCenterForValue(r.ticks.reverse?i.min:i.max),n=i.getPointPosition(t,e),a.beginPath(),a.moveTo(i.xCenter,i.yCenter),a.lineTo(n.x,n.y),a.stroke();a.restore()}},_drawLabels:function(){var t=this,e=t.ctx,n=t.options.ticks;if(n.display){var i,a,r=t.getIndexAngle(0),o=B.options._parseFont(n),s=zn(n.fontColor,Y.global.defaultFontColor);e.save(),e.font=o.string,e.translate(t.xCenter,t.yCenter),e.rotate(r),e.textAlign="center",e.textBaseline="middle",B.each(t.ticks,(function(r,l){(0!==l||n.reverse)&&(i=t.getDistanceFromCenterForValue(t.ticksAsNumbers[l]),n.showLabelBackdrop&&(a=e.measureText(r).width,e.fillStyle=n.backdropColor,e.fillRect(-a/2-n.backdropPaddingX,-i-o.size/2-n.backdropPaddingY,a+2*n.backdropPaddingX,o.size+2*n.backdropPaddingY)),e.fillStyle=s,e.fillText(r,0,-i))})),e.restore()}},_drawTitle:B.noop}),Xn=Hn;$n._defaults=Xn;var Kn=B._deprecated,Jn=B.options.resolve,Qn=B.valueOrDefault,ti=Number.MIN_SAFE_INTEGER||-9007199254740991,ei=Number.MAX_SAFE_INTEGER||9007199254740991,ni={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},ii=Object.keys(ni);function ai(t,e){return t-e}function ri(t){return B.valueOrDefault(t.time.min,t.ticks.min)}function oi(t){return B.valueOrDefault(t.time.max,t.ticks.max)}function si(t,e,n,i){var a=function(t,e,n){for(var i,a,r,o=0,s=t.length-1;o>=0&&o<=s;){if(a=t[(i=o+s>>1)-1]||null,r=t[i],!a)return{lo:null,hi:r};if(r[e]n))return{lo:a,hi:r};s=i-1}}return{lo:r,hi:null}}(t,e,n),r=a.lo?a.hi?a.lo:t[t.length-2]:t[0],o=a.lo?a.hi?a.hi:t[t.length-1]:t[1],s=o[e]-r[e],l=s?(n-r[e])/s:0,u=(o[i]-r[i])*l;return r[i]+u}function li(t,e){var n=t._adapter,i=t.options.time,a=i.parser,r=a||i.format,o=e;return"function"==typeof a&&(o=a(o)),B.isFinite(o)||(o="string"==typeof r?n.parse(o,r):n.parse(o)),null!==o?+o:(a||"function"!=typeof r||(o=r(e),B.isFinite(o)||(o=n.parse(o))),o)}function ui(t,e){if(B.isNullOrUndef(e))return null;var n=t.options.time,i=li(t,t.getRightValue(e));return null===i?i:(n.round&&(i=+t._adapter.startOf(i,n.round)),i)}function di(t,e,n,i){var a,r,o,s=ii.length;for(a=ii.indexOf(t);a=0&&(e[r].major=!0);return e}(t,r,o,n):r}var ci=_n.extend({initialize:function(){this.mergeTicksOptions(),_n.prototype.initialize.call(this)},update:function(){var t=this,e=t.options,n=e.time||(e.time={}),i=t._adapter=new on._date(e.adapters.date);return Kn("time scale",n.format,"time.format","time.parser"),Kn("time scale",n.min,"time.min","ticks.min"),Kn("time scale",n.max,"time.max","ticks.max"),B.mergeIf(n.displayFormats,i.formats()),_n.prototype.update.apply(t,arguments)},getRightValue:function(t){return t&&void 0!==t.t&&(t=t.t),_n.prototype.getRightValue.call(this,t)},determineDataLimits:function(){var t,e,n,i,a,r,o,s=this,l=s.chart,u=s._adapter,d=s.options,h=d.time.unit||"day",c=ei,f=ti,g=[],m=[],p=[],v=s._getLabels();for(t=0,n=v.length;t1?function(t){var e,n,i,a={},r=[];for(e=0,n=t.length;e1e5*u)throw e+" and "+n+" are too far apart with stepSize of "+u+" "+l;for(a=h;a=a&&n<=r&&d.push(n);return i.min=a,i.max=r,i._unit=l.unit||(s.autoSkip?di(l.minUnit,i.min,i.max,h):function(t,e,n,i,a){var r,o;for(r=ii.length-1;r>=ii.indexOf(n);r--)if(o=ii[r],ni[o].common&&t._adapter.diff(a,i,o)>=e-1)return o;return ii[n?ii.indexOf(n):0]}(i,d.length,l.minUnit,i.min,i.max)),i._majorUnit=s.major.enabled&&"year"!==i._unit?function(t){for(var e=ii.indexOf(t)+1,n=ii.length;ee&&s=0&&t0?s:1}}),fi={position:"bottom",distribution:"linear",bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,displayFormat:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{autoSkip:!1,source:"auto",major:{enabled:!1}}};ci._defaults=fi;var gi={category:kn,linear:An,logarithmic:Wn,radialLinear:$n,time:ci},mi=e((function(e,n){e.exports=function(){var n,i;function a(){return n.apply(null,arguments)}function r(t){return t instanceof Array||"[object Array]"===Object.prototype.toString.call(t)}function o(t){return null!=t&&"[object Object]"===Object.prototype.toString.call(t)}function s(t){return void 0===t}function l(t){return"number"==typeof t||"[object Number]"===Object.prototype.toString.call(t)}function u(t){return t instanceof Date||"[object Date]"===Object.prototype.toString.call(t)}function d(t,e){var n,i=[];for(n=0;n>>0,i=0;i0)for(n=0;n=0?n?"+":"":"-")+Math.pow(10,Math.max(0,a)).toString().substr(1)+i}var E=/(\[[^\[]*\])|(\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,V=/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,H={},B={};function j(t,e,n,i){var a=i;"string"==typeof i&&(a=function(){return this[i]()}),t&&(B[t]=a),e&&(B[e[0]]=function(){return z(a.apply(this,arguments),e[1],e[2])}),n&&(B[n]=function(){return this.localeData().ordinal(a.apply(this,arguments),t)})}function U(t,e){return t.isValid()?(e=G(e,t.localeData()),H[e]=H[e]||function(t){var e,n,i,a=t.match(E);for(e=0,n=a.length;e=0&&V.test(t);)t=t.replace(V,i),V.lastIndex=0,n-=1;return t}var q=/\d/,Z=/\d\d/,$=/\d{3}/,X=/\d{4}/,K=/[+-]?\d{6}/,J=/\d\d?/,Q=/\d\d\d\d?/,tt=/\d\d\d\d\d\d?/,et=/\d{1,3}/,nt=/\d{1,4}/,it=/[+-]?\d{1,6}/,at=/\d+/,rt=/[+-]?\d+/,ot=/Z|[+-]\d\d:?\d\d/gi,st=/Z|[+-]\d\d(?::?\d\d)?/gi,lt=/[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i,ut={};function dt(t,e,n){ut[t]=O(e)?e:function(t,i){return t&&n?n:e}}function ht(t,e){return h(ut,t)?ut[t](e._strict,e._locale):new RegExp(ct(t.replace("\\","").replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,(function(t,e,n,i,a){return e||n||i||a}))))}function ct(t){return t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}var ft={};function gt(t,e){var n,i=e;for("string"==typeof t&&(t=[t]),l(e)&&(i=function(t,n){n[e]=k(t)}),n=0;n68?1900:2e3)};var Pt,Tt=Ot("FullYear",!0);function Ot(t,e){return function(n){return null!=n?(Ft(this,t,n),a.updateOffset(this,e),this):At(this,t)}}function At(t,e){return t.isValid()?t._d["get"+(t._isUTC?"UTC":"")+e]():NaN}function Ft(t,e,n){t.isValid()&&!isNaN(n)&&("FullYear"===e&&Ct(t.year())&&1===t.month()&&29===t.date()?t._d["set"+(t._isUTC?"UTC":"")+e](n,t.month(),It(n,t.month())):t._d["set"+(t._isUTC?"UTC":"")+e](n))}function It(t,e){if(isNaN(t)||isNaN(e))return NaN;var n=function(t,e){return(t%e+e)%e}(e,12);return t+=(e-n)/12,1===n?Ct(t)?29:28:31-n%7%2}Pt=Array.prototype.indexOf?Array.prototype.indexOf:function(t){var e;for(e=0;e=0?(s=new Date(t+400,e,n,i,a,r,o),isFinite(s.getFullYear())&&s.setFullYear(t)):s=new Date(t,e,n,i,a,r,o),s}function jt(t){var e;if(t<100&&t>=0){var n=Array.prototype.slice.call(arguments);n[0]=t+400,e=new Date(Date.UTC.apply(null,n)),isFinite(e.getUTCFullYear())&&e.setUTCFullYear(t)}else e=new Date(Date.UTC.apply(null,arguments));return e}function Ut(t,e,n){var i=7+e-n;return-(7+jt(t,0,i).getUTCDay()-e)%7+i-1}function Gt(t,e,n,i,a){var r,o,s=1+7*(e-1)+(7+n-i)%7+Ut(t,i,a);return s<=0?o=Dt(r=t-1)+s:s>Dt(t)?(r=t+1,o=s-Dt(t)):(r=t,o=s),{year:r,dayOfYear:o}}function qt(t,e,n){var i,a,r=Ut(t.year(),e,n),o=Math.floor((t.dayOfYear()-r-1)/7)+1;return o<1?i=o+Zt(a=t.year()-1,e,n):o>Zt(t.year(),e,n)?(i=o-Zt(t.year(),e,n),a=t.year()+1):(a=t.year(),i=o),{week:i,year:a}}function Zt(t,e,n){var i=Ut(t,e,n),a=Ut(t+1,e,n);return(Dt(t)-i+a)/7}function $t(t,e){return t.slice(e,7).concat(t.slice(0,e))}j("w",["ww",2],"wo","week"),j("W",["WW",2],"Wo","isoWeek"),L("week","w"),L("isoWeek","W"),Y("week",5),Y("isoWeek",5),dt("w",J),dt("ww",J,Z),dt("W",J),dt("WW",J,Z),mt(["w","ww","W","WW"],(function(t,e,n,i){e[i.substr(0,1)]=k(t)})),j("d",0,"do","day"),j("dd",0,0,(function(t){return this.localeData().weekdaysMin(this,t)})),j("ddd",0,0,(function(t){return this.localeData().weekdaysShort(this,t)})),j("dddd",0,0,(function(t){return this.localeData().weekdays(this,t)})),j("e",0,0,"weekday"),j("E",0,0,"isoWeekday"),L("day","d"),L("weekday","e"),L("isoWeekday","E"),Y("day",11),Y("weekday",11),Y("isoWeekday",11),dt("d",J),dt("e",J),dt("E",J),dt("dd",(function(t,e){return e.weekdaysMinRegex(t)})),dt("ddd",(function(t,e){return e.weekdaysShortRegex(t)})),dt("dddd",(function(t,e){return e.weekdaysRegex(t)})),mt(["dd","ddd","dddd"],(function(t,e,n,i){var a=n._locale.weekdaysParse(t,i,n._strict);null!=a?e.d=a:g(n).invalidWeekday=t})),mt(["d","e","E"],(function(t,e,n,i){e[i]=k(t)}));var Xt="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),Kt="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),Jt="Su_Mo_Tu_We_Th_Fr_Sa".split("_");function Qt(t,e,n){var i,a,r,o=t.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],i=0;i<7;++i)r=f([2e3,1]).day(i),this._minWeekdaysParse[i]=this.weekdaysMin(r,"").toLocaleLowerCase(),this._shortWeekdaysParse[i]=this.weekdaysShort(r,"").toLocaleLowerCase(),this._weekdaysParse[i]=this.weekdays(r,"").toLocaleLowerCase();return n?"dddd"===e?-1!==(a=Pt.call(this._weekdaysParse,o))?a:null:"ddd"===e?-1!==(a=Pt.call(this._shortWeekdaysParse,o))?a:null:-1!==(a=Pt.call(this._minWeekdaysParse,o))?a:null:"dddd"===e?-1!==(a=Pt.call(this._weekdaysParse,o))?a:-1!==(a=Pt.call(this._shortWeekdaysParse,o))?a:-1!==(a=Pt.call(this._minWeekdaysParse,o))?a:null:"ddd"===e?-1!==(a=Pt.call(this._shortWeekdaysParse,o))?a:-1!==(a=Pt.call(this._weekdaysParse,o))?a:-1!==(a=Pt.call(this._minWeekdaysParse,o))?a:null:-1!==(a=Pt.call(this._minWeekdaysParse,o))?a:-1!==(a=Pt.call(this._weekdaysParse,o))?a:-1!==(a=Pt.call(this._shortWeekdaysParse,o))?a:null}var te=lt,ee=lt,ne=lt;function ie(){function t(t,e){return e.length-t.length}var e,n,i,a,r,o=[],s=[],l=[],u=[];for(e=0;e<7;e++)n=f([2e3,1]).day(e),i=this.weekdaysMin(n,""),a=this.weekdaysShort(n,""),r=this.weekdays(n,""),o.push(i),s.push(a),l.push(r),u.push(i),u.push(a),u.push(r);for(o.sort(t),s.sort(t),l.sort(t),u.sort(t),e=0;e<7;e++)s[e]=ct(s[e]),l[e]=ct(l[e]),u[e]=ct(u[e]);this._weekdaysRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+s.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+o.join("|")+")","i")}function ae(){return this.hours()%12||12}function re(t,e){j(t,0,0,(function(){return this.localeData().meridiem(this.hours(),this.minutes(),e)}))}function oe(t,e){return e._meridiemParse}j("H",["HH",2],0,"hour"),j("h",["hh",2],0,ae),j("k",["kk",2],0,(function(){return this.hours()||24})),j("hmm",0,0,(function(){return""+ae.apply(this)+z(this.minutes(),2)})),j("hmmss",0,0,(function(){return""+ae.apply(this)+z(this.minutes(),2)+z(this.seconds(),2)})),j("Hmm",0,0,(function(){return""+this.hours()+z(this.minutes(),2)})),j("Hmmss",0,0,(function(){return""+this.hours()+z(this.minutes(),2)+z(this.seconds(),2)})),re("a",!0),re("A",!1),L("hour","h"),Y("hour",13),dt("a",oe),dt("A",oe),dt("H",J),dt("h",J),dt("k",J),dt("HH",J,Z),dt("hh",J,Z),dt("kk",J,Z),dt("hmm",Q),dt("hmmss",tt),dt("Hmm",Q),dt("Hmmss",tt),gt(["H","HH"],xt),gt(["k","kk"],(function(t,e,n){var i=k(t);e[xt]=24===i?0:i})),gt(["a","A"],(function(t,e,n){n._isPm=n._locale.isPM(t),n._meridiem=t})),gt(["h","hh"],(function(t,e,n){e[xt]=k(t),g(n).bigHour=!0})),gt("hmm",(function(t,e,n){var i=t.length-2;e[xt]=k(t.substr(0,i)),e[_t]=k(t.substr(i)),g(n).bigHour=!0})),gt("hmmss",(function(t,e,n){var i=t.length-4,a=t.length-2;e[xt]=k(t.substr(0,i)),e[_t]=k(t.substr(i,2)),e[wt]=k(t.substr(a)),g(n).bigHour=!0})),gt("Hmm",(function(t,e,n){var i=t.length-2;e[xt]=k(t.substr(0,i)),e[_t]=k(t.substr(i))})),gt("Hmmss",(function(t,e,n){var i=t.length-4,a=t.length-2;e[xt]=k(t.substr(0,i)),e[_t]=k(t.substr(i,2)),e[wt]=k(t.substr(a))}));var se,le=Ot("Hours",!0),ue={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Rt,monthsShort:Nt,week:{dow:0,doy:6},weekdays:Xt,weekdaysMin:Jt,weekdaysShort:Kt,meridiemParse:/[ap]\.?m?\.?/i},de={},he={};function ce(t){return t?t.toLowerCase().replace("_","-"):t}function fe(n){var i=null;if(!de[n]&&e&&e.exports)try{i=se._abbr,t(),ge(i)}catch(t){}return de[n]}function ge(t,e){var n;return t&&((n=s(e)?pe(t):me(t,e))?se=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+t+" not found. Did you forget to load it?")),se._abbr}function me(t,e){if(null!==e){var n,i=ue;if(e.abbr=t,null!=de[t])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),i=de[t]._config;else if(null!=e.parentLocale)if(null!=de[e.parentLocale])i=de[e.parentLocale]._config;else{if(null==(n=fe(e.parentLocale)))return he[e.parentLocale]||(he[e.parentLocale]=[]),he[e.parentLocale].push({name:t,config:e}),null;i=n._config}return de[t]=new F(A(i,e)),he[t]&&he[t].forEach((function(t){me(t.name,t.config)})),ge(t),de[t]}return delete de[t],null}function pe(t){var e;if(t&&t._locale&&t._locale._abbr&&(t=t._locale._abbr),!t)return se;if(!r(t)){if(e=fe(t))return e;t=[t]}return function(t){for(var e,n,i,a,r=0;r0;){if(i=fe(a.slice(0,e).join("-")))return i;if(n&&n.length>=e&&M(a,n,!0)>=e-1)break;e--}r++}return se}(t)}function ve(t){var e,n=t._a;return n&&-2===g(t).overflow&&(e=n[bt]<0||n[bt]>11?bt:n[yt]<1||n[yt]>It(n[vt],n[bt])?yt:n[xt]<0||n[xt]>24||24===n[xt]&&(0!==n[_t]||0!==n[wt]||0!==n[kt])?xt:n[_t]<0||n[_t]>59?_t:n[wt]<0||n[wt]>59?wt:n[kt]<0||n[kt]>999?kt:-1,g(t)._overflowDayOfYear&&(eyt)&&(e=yt),g(t)._overflowWeeks&&-1===e&&(e=Mt),g(t)._overflowWeekday&&-1===e&&(e=St),g(t).overflow=e),t}function be(t,e,n){return null!=t?t:null!=e?e:n}function ye(t){var e,n,i,r,o,s=[];if(!t._d){for(i=function(t){var e=new Date(a.now());return t._useUTC?[e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()]:[e.getFullYear(),e.getMonth(),e.getDate()]}(t),t._w&&null==t._a[yt]&&null==t._a[bt]&&function(t){var e,n,i,a,r,o,s,l;if(null!=(e=t._w).GG||null!=e.W||null!=e.E)r=1,o=4,n=be(e.GG,t._a[vt],qt(Le(),1,4).year),i=be(e.W,1),((a=be(e.E,1))<1||a>7)&&(l=!0);else{r=t._locale._week.dow,o=t._locale._week.doy;var u=qt(Le(),r,o);n=be(e.gg,t._a[vt],u.year),i=be(e.w,u.week),null!=e.d?((a=e.d)<0||a>6)&&(l=!0):null!=e.e?(a=e.e+r,(e.e<0||e.e>6)&&(l=!0)):a=r}i<1||i>Zt(n,r,o)?g(t)._overflowWeeks=!0:null!=l?g(t)._overflowWeekday=!0:(s=Gt(n,i,a,r,o),t._a[vt]=s.year,t._dayOfYear=s.dayOfYear)}(t),null!=t._dayOfYear&&(o=be(t._a[vt],i[vt]),(t._dayOfYear>Dt(o)||0===t._dayOfYear)&&(g(t)._overflowDayOfYear=!0),n=jt(o,0,t._dayOfYear),t._a[bt]=n.getUTCMonth(),t._a[yt]=n.getUTCDate()),e=0;e<3&&null==t._a[e];++e)t._a[e]=s[e]=i[e];for(;e<7;e++)t._a[e]=s[e]=null==t._a[e]?2===e?1:0:t._a[e];24===t._a[xt]&&0===t._a[_t]&&0===t._a[wt]&&0===t._a[kt]&&(t._nextDay=!0,t._a[xt]=0),t._d=(t._useUTC?jt:Bt).apply(null,s),r=t._useUTC?t._d.getUTCDay():t._d.getDay(),null!=t._tzm&&t._d.setUTCMinutes(t._d.getUTCMinutes()-t._tzm),t._nextDay&&(t._a[xt]=24),t._w&&void 0!==t._w.d&&t._w.d!==r&&(g(t).weekdayMismatch=!0)}}var xe=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_e=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,we=/Z|[+-]\d\d(?::?\d\d)?/,ke=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],Me=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],Se=/^\/?Date\((\-?\d+)/i;function De(t){var e,n,i,a,r,o,s=t._i,l=xe.exec(s)||_e.exec(s);if(l){for(g(t).iso=!0,e=0,n=ke.length;e0&&g(t).unusedInput.push(o),s=s.slice(s.indexOf(n)+n.length),u+=n.length),B[r]?(n?g(t).empty=!1:g(t).unusedTokens.push(r),pt(r,n,t)):t._strict&&!n&&g(t).unusedTokens.push(r);g(t).charsLeftOver=l-u,s.length>0&&g(t).unusedInput.push(s),t._a[xt]<=12&&!0===g(t).bigHour&&t._a[xt]>0&&(g(t).bigHour=void 0),g(t).parsedDateParts=t._a.slice(0),g(t).meridiem=t._meridiem,t._a[xt]=function(t,e,n){var i;return null==n?e:null!=t.meridiemHour?t.meridiemHour(e,n):null!=t.isPM?((i=t.isPM(n))&&e<12&&(e+=12),i||12!==e||(e=0),e):e}(t._locale,t._a[xt],t._meridiem),ye(t),ve(t)}else Oe(t);else De(t)}function Fe(t){var e=t._i,n=t._f;return t._locale=t._locale||pe(t._l),null===e||void 0===n&&""===e?p({nullInput:!0}):("string"==typeof e&&(t._i=e=t._locale.preparse(e)),_(e)?new x(ve(e)):(u(e)?t._d=e:r(n)?function(t){var e,n,i,a,r;if(0===t._f.length)return g(t).invalidFormat=!0,void(t._d=new Date(NaN));for(a=0;athis?this:t:p()}));function We(t,e){var n,i;if(1===e.length&&r(e[0])&&(e=e[0]),!e.length)return Le();for(n=e[0],i=1;i=0?new Date(t+400,e,n)-hn:new Date(t,e,n).valueOf()}function gn(t,e,n){return t<100&&t>=0?Date.UTC(t+400,e,n)-hn:Date.UTC(t,e,n)}function mn(t,e){j(0,[t,t.length],0,e)}function pn(t,e,n,i,a){var r;return null==t?qt(this,i,a).year:(e>(r=Zt(t,i,a))&&(e=r),vn.call(this,t,e,n,i,a))}function vn(t,e,n,i,a){var r=Gt(t,e,n,i,a),o=jt(r.year,0,r.dayOfYear);return this.year(o.getUTCFullYear()),this.month(o.getUTCMonth()),this.date(o.getUTCDate()),this}j(0,["gg",2],0,(function(){return this.weekYear()%100})),j(0,["GG",2],0,(function(){return this.isoWeekYear()%100})),mn("gggg","weekYear"),mn("ggggg","weekYear"),mn("GGGG","isoWeekYear"),mn("GGGGG","isoWeekYear"),L("weekYear","gg"),L("isoWeekYear","GG"),Y("weekYear",1),Y("isoWeekYear",1),dt("G",rt),dt("g",rt),dt("GG",J,Z),dt("gg",J,Z),dt("GGGG",nt,X),dt("gggg",nt,X),dt("GGGGG",it,K),dt("ggggg",it,K),mt(["gggg","ggggg","GGGG","GGGGG"],(function(t,e,n,i){e[i.substr(0,2)]=k(t)})),mt(["gg","GG"],(function(t,e,n,i){e[i]=a.parseTwoDigitYear(t)})),j("Q",0,"Qo","quarter"),L("quarter","Q"),Y("quarter",7),dt("Q",q),gt("Q",(function(t,e){e[bt]=3*(k(t)-1)})),j("D",["DD",2],"Do","date"),L("date","D"),Y("date",9),dt("D",J),dt("DD",J,Z),dt("Do",(function(t,e){return t?e._dayOfMonthOrdinalParse||e._ordinalParse:e._dayOfMonthOrdinalParseLenient})),gt(["D","DD"],yt),gt("Do",(function(t,e){e[yt]=k(t.match(J)[0])}));var bn=Ot("Date",!0);j("DDD",["DDDD",3],"DDDo","dayOfYear"),L("dayOfYear","DDD"),Y("dayOfYear",4),dt("DDD",et),dt("DDDD",$),gt(["DDD","DDDD"],(function(t,e,n){n._dayOfYear=k(t)})),j("m",["mm",2],0,"minute"),L("minute","m"),Y("minute",14),dt("m",J),dt("mm",J,Z),gt(["m","mm"],_t);var yn=Ot("Minutes",!1);j("s",["ss",2],0,"second"),L("second","s"),Y("second",15),dt("s",J),dt("ss",J,Z),gt(["s","ss"],wt);var xn,_n=Ot("Seconds",!1);for(j("S",0,0,(function(){return~~(this.millisecond()/100)})),j(0,["SS",2],0,(function(){return~~(this.millisecond()/10)})),j(0,["SSS",3],0,"millisecond"),j(0,["SSSS",4],0,(function(){return 10*this.millisecond()})),j(0,["SSSSS",5],0,(function(){return 100*this.millisecond()})),j(0,["SSSSSS",6],0,(function(){return 1e3*this.millisecond()})),j(0,["SSSSSSS",7],0,(function(){return 1e4*this.millisecond()})),j(0,["SSSSSSSS",8],0,(function(){return 1e5*this.millisecond()})),j(0,["SSSSSSSSS",9],0,(function(){return 1e6*this.millisecond()})),L("millisecond","ms"),Y("millisecond",16),dt("S",et,q),dt("SS",et,Z),dt("SSS",et,$),xn="SSSS";xn.length<=9;xn+="S")dt(xn,at);function wn(t,e){e[kt]=k(1e3*("0."+t))}for(xn="S";xn.length<=9;xn+="S")gt(xn,wn);var kn=Ot("Milliseconds",!1);j("z",0,0,"zoneAbbr"),j("zz",0,0,"zoneName");var Mn=x.prototype;function Sn(t){return t}Mn.add=en,Mn.calendar=function(t,e){var n=t||Le(),i=Ue(n,this).startOf("day"),r=a.calendarFormat(this,i)||"sameElse",o=e&&(O(e[r])?e[r].call(this,n):e[r]);return this.format(o||this.localeData().calendar(r,this,Le(n)))},Mn.clone=function(){return new x(this)},Mn.diff=function(t,e,n){var i,a,r;if(!this.isValid())return NaN;if(!(i=Ue(t,this)).isValid())return NaN;switch(a=6e4*(i.utcOffset()-this.utcOffset()),e=R(e)){case"year":r=an(this,i)/12;break;case"month":r=an(this,i);break;case"quarter":r=an(this,i)/3;break;case"second":r=(this-i)/1e3;break;case"minute":r=(this-i)/6e4;break;case"hour":r=(this-i)/36e5;break;case"day":r=(this-i-a)/864e5;break;case"week":r=(this-i-a)/6048e5;break;default:r=this-i}return n?r:w(r)},Mn.endOf=function(t){var e;if(void 0===(t=R(t))||"millisecond"===t||!this.isValid())return this;var n=this._isUTC?gn:fn;switch(t){case"year":e=n(this.year()+1,0,1)-1;break;case"quarter":e=n(this.year(),this.month()-this.month()%3+3,1)-1;break;case"month":e=n(this.year(),this.month()+1,1)-1;break;case"week":e=n(this.year(),this.month(),this.date()-this.weekday()+7)-1;break;case"isoWeek":e=n(this.year(),this.month(),this.date()-(this.isoWeekday()-1)+7)-1;break;case"day":case"date":e=n(this.year(),this.month(),this.date()+1)-1;break;case"hour":e=this._d.valueOf(),e+=dn-cn(e+(this._isUTC?0:this.utcOffset()*un),dn)-1;break;case"minute":e=this._d.valueOf(),e+=un-cn(e,un)-1;break;case"second":e=this._d.valueOf(),e+=ln-cn(e,ln)-1}return this._d.setTime(e),a.updateOffset(this,!0),this},Mn.format=function(t){t||(t=this.isUtc()?a.defaultFormatUtc:a.defaultFormat);var e=U(this,t);return this.localeData().postformat(e)},Mn.from=function(t,e){return this.isValid()&&(_(t)&&t.isValid()||Le(t).isValid())?Xe({to:this,from:t}).locale(this.locale()).humanize(!e):this.localeData().invalidDate()},Mn.fromNow=function(t){return this.from(Le(),t)},Mn.to=function(t,e){return this.isValid()&&(_(t)&&t.isValid()||Le(t).isValid())?Xe({from:this,to:t}).locale(this.locale()).humanize(!e):this.localeData().invalidDate()},Mn.toNow=function(t){return this.to(Le(),t)},Mn.get=function(t){return O(this[t=R(t)])?this[t]():this},Mn.invalidAt=function(){return g(this).overflow},Mn.isAfter=function(t,e){var n=_(t)?t:Le(t);return!(!this.isValid()||!n.isValid())&&("millisecond"===(e=R(e)||"millisecond")?this.valueOf()>n.valueOf():n.valueOf()9999?U(n,e?"YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]":"YYYYYY-MM-DD[T]HH:mm:ss.SSSZ"):O(Date.prototype.toISOString)?e?this.toDate().toISOString():new Date(this.valueOf()+60*this.utcOffset()*1e3).toISOString().replace("Z",U(n,"Z")):U(n,e?"YYYY-MM-DD[T]HH:mm:ss.SSS[Z]":"YYYY-MM-DD[T]HH:mm:ss.SSSZ")},Mn.inspect=function(){if(!this.isValid())return"moment.invalid(/* "+this._i+" */)";var t="moment",e="";this.isLocal()||(t=0===this.utcOffset()?"moment.utc":"moment.parseZone",e="Z");var n="["+t+'("]',i=0<=this.year()&&this.year()<=9999?"YYYY":"YYYYYY",a=e+'[")]';return this.format(n+i+"-MM-DD[T]HH:mm:ss.SSS"+a)},Mn.toJSON=function(){return this.isValid()?this.toISOString():null},Mn.toString=function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},Mn.unix=function(){return Math.floor(this.valueOf()/1e3)},Mn.valueOf=function(){return this._d.valueOf()-6e4*(this._offset||0)},Mn.creationData=function(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}},Mn.year=Tt,Mn.isLeapYear=function(){return Ct(this.year())},Mn.weekYear=function(t){return pn.call(this,t,this.week(),this.weekday(),this.localeData()._week.dow,this.localeData()._week.doy)},Mn.isoWeekYear=function(t){return pn.call(this,t,this.isoWeek(),this.isoWeekday(),1,4)},Mn.quarter=Mn.quarters=function(t){return null==t?Math.ceil((this.month()+1)/3):this.month(3*(t-1)+this.month()%3)},Mn.month=zt,Mn.daysInMonth=function(){return It(this.year(),this.month())},Mn.week=Mn.weeks=function(t){var e=this.localeData().week(this);return null==t?e:this.add(7*(t-e),"d")},Mn.isoWeek=Mn.isoWeeks=function(t){var e=qt(this,1,4).week;return null==t?e:this.add(7*(t-e),"d")},Mn.weeksInYear=function(){var t=this.localeData()._week;return Zt(this.year(),t.dow,t.doy)},Mn.isoWeeksInYear=function(){return Zt(this.year(),1,4)},Mn.date=bn,Mn.day=Mn.days=function(t){if(!this.isValid())return null!=t?this:NaN;var e=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=t?(t=function(t,e){return"string"!=typeof t?t:isNaN(t)?"number"==typeof(t=e.weekdaysParse(t))?t:null:parseInt(t,10)}(t,this.localeData()),this.add(t-e,"d")):e},Mn.weekday=function(t){if(!this.isValid())return null!=t?this:NaN;var e=(this.day()+7-this.localeData()._week.dow)%7;return null==t?e:this.add(t-e,"d")},Mn.isoWeekday=function(t){if(!this.isValid())return null!=t?this:NaN;if(null!=t){var e=function(t,e){return"string"==typeof t?e.weekdaysParse(t)%7||7:isNaN(t)?null:t}(t,this.localeData());return this.day(this.day()%7?e:e-7)}return this.day()||7},Mn.dayOfYear=function(t){var e=Math.round((this.clone().startOf("day")-this.clone().startOf("year"))/864e5)+1;return null==t?e:this.add(t-e,"d")},Mn.hour=Mn.hours=le,Mn.minute=Mn.minutes=yn,Mn.second=Mn.seconds=_n,Mn.millisecond=Mn.milliseconds=kn,Mn.utcOffset=function(t,e,n){var i,r=this._offset||0;if(!this.isValid())return null!=t?this:NaN;if(null!=t){if("string"==typeof t){if(null===(t=je(st,t)))return this}else Math.abs(t)<16&&!n&&(t*=60);return!this._isUTC&&e&&(i=Ge(this)),this._offset=t,this._isUTC=!0,null!=i&&this.add(i,"m"),r!==t&&(!e||this._changeInProgress?tn(this,Xe(t-r,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,a.updateOffset(this,!0),this._changeInProgress=null)),this}return this._isUTC?r:Ge(this)},Mn.utc=function(t){return this.utcOffset(0,t)},Mn.local=function(t){return this._isUTC&&(this.utcOffset(0,t),this._isUTC=!1,t&&this.subtract(Ge(this),"m")),this},Mn.parseZone=function(){if(null!=this._tzm)this.utcOffset(this._tzm,!1,!0);else if("string"==typeof this._i){var t=je(ot,this._i);null!=t?this.utcOffset(t):this.utcOffset(0,!0)}return this},Mn.hasAlignedHourOffset=function(t){return!!this.isValid()&&(t=t?Le(t).utcOffset():0,(this.utcOffset()-t)%60==0)},Mn.isDST=function(){return this.utcOffset()>this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},Mn.isLocal=function(){return!!this.isValid()&&!this._isUTC},Mn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},Mn.isUtc=qe,Mn.isUTC=qe,Mn.zoneAbbr=function(){return this._isUTC?"UTC":""},Mn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},Mn.dates=D("dates accessor is deprecated. Use date instead.",bn),Mn.months=D("months accessor is deprecated. Use month instead",zt),Mn.years=D("years accessor is deprecated. Use year instead",Tt),Mn.zone=D("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",(function(t,e){return null!=t?("string"!=typeof t&&(t=-t),this.utcOffset(t,e),this):-this.utcOffset()})),Mn.isDSTShifted=D("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",(function(){if(!s(this._isDSTShifted))return this._isDSTShifted;var t={};if(b(t,this),(t=Fe(t))._a){var e=t._isUTC?f(t._a):Le(t._a);this._isDSTShifted=this.isValid()&&M(t._a,e.toArray())>0}else this._isDSTShifted=!1;return this._isDSTShifted}));var Dn=F.prototype;function Cn(t,e,n,i){var a=pe(),r=f().set(i,e);return a[n](r,t)}function Pn(t,e,n){if(l(t)&&(e=t,t=void 0),t=t||"",null!=e)return Cn(t,e,n,"month");var i,a=[];for(i=0;i<12;i++)a[i]=Cn(t,i,n,"month");return a}function Tn(t,e,n,i){"boolean"==typeof t?(l(e)&&(n=e,e=void 0),e=e||""):(n=e=t,t=!1,l(e)&&(n=e,e=void 0),e=e||"");var a,r=pe(),o=t?r._week.dow:0;if(null!=n)return Cn(e,(n+o)%7,i,"day");var s=[];for(a=0;a<7;a++)s[a]=Cn(e,(a+o)%7,i,"day");return s}Dn.calendar=function(t,e,n){var i=this._calendar[t]||this._calendar.sameElse;return O(i)?i.call(e,n):i},Dn.longDateFormat=function(t){var e=this._longDateFormat[t],n=this._longDateFormat[t.toUpperCase()];return e||!n?e:(this._longDateFormat[t]=n.replace(/MMMM|MM|DD|dddd/g,(function(t){return t.slice(1)})),this._longDateFormat[t])},Dn.invalidDate=function(){return this._invalidDate},Dn.ordinal=function(t){return this._ordinal.replace("%d",t)},Dn.preparse=Sn,Dn.postformat=Sn,Dn.relativeTime=function(t,e,n,i){var a=this._relativeTime[n];return O(a)?a(t,e,n,i):a.replace(/%d/i,t)},Dn.pastFuture=function(t,e){var n=this._relativeTime[t>0?"future":"past"];return O(n)?n(e):n.replace(/%s/i,e)},Dn.set=function(t){var e,n;for(n in t)O(e=t[n])?this[n]=e:this["_"+n]=e;this._config=t,this._dayOfMonthOrdinalParseLenient=new RegExp((this._dayOfMonthOrdinalParse.source||this._ordinalParse.source)+"|"+/\d{1,2}/.source)},Dn.months=function(t,e){return t?r(this._months)?this._months[t.month()]:this._months[(this._months.isFormat||Lt).test(e)?"format":"standalone"][t.month()]:r(this._months)?this._months:this._months.standalone},Dn.monthsShort=function(t,e){return t?r(this._monthsShort)?this._monthsShort[t.month()]:this._monthsShort[Lt.test(e)?"format":"standalone"][t.month()]:r(this._monthsShort)?this._monthsShort:this._monthsShort.standalone},Dn.monthsParse=function(t,e,n){var i,a,r;if(this._monthsParseExact)return Wt.call(this,t,e,n);for(this._monthsParse||(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[]),i=0;i<12;i++){if(a=f([2e3,i]),n&&!this._longMonthsParse[i]&&(this._longMonthsParse[i]=new RegExp("^"+this.months(a,"").replace(".","")+"$","i"),this._shortMonthsParse[i]=new RegExp("^"+this.monthsShort(a,"").replace(".","")+"$","i")),n||this._monthsParse[i]||(r="^"+this.months(a,"")+"|^"+this.monthsShort(a,""),this._monthsParse[i]=new RegExp(r.replace(".",""),"i")),n&&"MMMM"===e&&this._longMonthsParse[i].test(t))return i;if(n&&"MMM"===e&&this._shortMonthsParse[i].test(t))return i;if(!n&&this._monthsParse[i].test(t))return i}},Dn.monthsRegex=function(t){return this._monthsParseExact?(h(this,"_monthsRegex")||Ht.call(this),t?this._monthsStrictRegex:this._monthsRegex):(h(this,"_monthsRegex")||(this._monthsRegex=Vt),this._monthsStrictRegex&&t?this._monthsStrictRegex:this._monthsRegex)},Dn.monthsShortRegex=function(t){return this._monthsParseExact?(h(this,"_monthsRegex")||Ht.call(this),t?this._monthsShortStrictRegex:this._monthsShortRegex):(h(this,"_monthsShortRegex")||(this._monthsShortRegex=Et),this._monthsShortStrictRegex&&t?this._monthsShortStrictRegex:this._monthsShortRegex)},Dn.week=function(t){return qt(t,this._week.dow,this._week.doy).week},Dn.firstDayOfYear=function(){return this._week.doy},Dn.firstDayOfWeek=function(){return this._week.dow},Dn.weekdays=function(t,e){var n=r(this._weekdays)?this._weekdays:this._weekdays[t&&!0!==t&&this._weekdays.isFormat.test(e)?"format":"standalone"];return!0===t?$t(n,this._week.dow):t?n[t.day()]:n},Dn.weekdaysMin=function(t){return!0===t?$t(this._weekdaysMin,this._week.dow):t?this._weekdaysMin[t.day()]:this._weekdaysMin},Dn.weekdaysShort=function(t){return!0===t?$t(this._weekdaysShort,this._week.dow):t?this._weekdaysShort[t.day()]:this._weekdaysShort},Dn.weekdaysParse=function(t,e,n){var i,a,r;if(this._weekdaysParseExact)return Qt.call(this,t,e,n);for(this._weekdaysParse||(this._weekdaysParse=[],this._minWeekdaysParse=[],this._shortWeekdaysParse=[],this._fullWeekdaysParse=[]),i=0;i<7;i++){if(a=f([2e3,1]).day(i),n&&!this._fullWeekdaysParse[i]&&(this._fullWeekdaysParse[i]=new RegExp("^"+this.weekdays(a,"").replace(".","\\.?")+"$","i"),this._shortWeekdaysParse[i]=new RegExp("^"+this.weekdaysShort(a,"").replace(".","\\.?")+"$","i"),this._minWeekdaysParse[i]=new RegExp("^"+this.weekdaysMin(a,"").replace(".","\\.?")+"$","i")),this._weekdaysParse[i]||(r="^"+this.weekdays(a,"")+"|^"+this.weekdaysShort(a,"")+"|^"+this.weekdaysMin(a,""),this._weekdaysParse[i]=new RegExp(r.replace(".",""),"i")),n&&"dddd"===e&&this._fullWeekdaysParse[i].test(t))return i;if(n&&"ddd"===e&&this._shortWeekdaysParse[i].test(t))return i;if(n&&"dd"===e&&this._minWeekdaysParse[i].test(t))return i;if(!n&&this._weekdaysParse[i].test(t))return i}},Dn.weekdaysRegex=function(t){return this._weekdaysParseExact?(h(this,"_weekdaysRegex")||ie.call(this),t?this._weekdaysStrictRegex:this._weekdaysRegex):(h(this,"_weekdaysRegex")||(this._weekdaysRegex=te),this._weekdaysStrictRegex&&t?this._weekdaysStrictRegex:this._weekdaysRegex)},Dn.weekdaysShortRegex=function(t){return this._weekdaysParseExact?(h(this,"_weekdaysRegex")||ie.call(this),t?this._weekdaysShortStrictRegex:this._weekdaysShortRegex):(h(this,"_weekdaysShortRegex")||(this._weekdaysShortRegex=ee),this._weekdaysShortStrictRegex&&t?this._weekdaysShortStrictRegex:this._weekdaysShortRegex)},Dn.weekdaysMinRegex=function(t){return this._weekdaysParseExact?(h(this,"_weekdaysRegex")||ie.call(this),t?this._weekdaysMinStrictRegex:this._weekdaysMinRegex):(h(this,"_weekdaysMinRegex")||(this._weekdaysMinRegex=ne),this._weekdaysMinStrictRegex&&t?this._weekdaysMinStrictRegex:this._weekdaysMinRegex)},Dn.isPM=function(t){return"p"===(t+"").toLowerCase().charAt(0)},Dn.meridiem=function(t,e,n){return t>11?n?"pm":"PM":n?"am":"AM"},ge("en",{dayOfMonthOrdinalParse:/\d{1,2}(th|st|nd|rd)/,ordinal:function(t){var e=t%10;return t+(1===k(t%100/10)?"th":1===e?"st":2===e?"nd":3===e?"rd":"th")}}),a.lang=D("moment.lang is deprecated. Use moment.locale instead.",ge),a.langData=D("moment.langData is deprecated. Use moment.localeData instead.",pe);var On=Math.abs;function An(t,e,n,i){var a=Xe(e,n);return t._milliseconds+=i*a._milliseconds,t._days+=i*a._days,t._months+=i*a._months,t._bubble()}function Fn(t){return t<0?Math.floor(t):Math.ceil(t)}function In(t){return 4800*t/146097}function Ln(t){return 146097*t/4800}function Rn(t){return function(){return this.as(t)}}var Nn=Rn("ms"),Wn=Rn("s"),Yn=Rn("m"),zn=Rn("h"),En=Rn("d"),Vn=Rn("w"),Hn=Rn("M"),Bn=Rn("Q"),jn=Rn("y");function Un(t){return function(){return this.isValid()?this._data[t]:NaN}}var Gn=Un("milliseconds"),qn=Un("seconds"),Zn=Un("minutes"),$n=Un("hours"),Xn=Un("days"),Kn=Un("months"),Jn=Un("years"),Qn=Math.round,ti={ss:44,s:45,m:45,h:22,d:26,M:11};function ei(t,e,n,i,a){return a.relativeTime(e||1,!!n,t,i)}var ni=Math.abs;function ii(t){return(t>0)-(t<0)||+t}function ai(){if(!this.isValid())return this.localeData().invalidDate();var t,e,n=ni(this._milliseconds)/1e3,i=ni(this._days),a=ni(this._months);t=w(n/60),e=w(t/60),n%=60,t%=60;var r=w(a/12),o=a%=12,s=i,l=e,u=t,d=n?n.toFixed(3).replace(/\.?0+$/,""):"",h=this.asSeconds();if(!h)return"P0D";var c=h<0?"-":"",f=ii(this._months)!==ii(h)?"-":"",g=ii(this._days)!==ii(h)?"-":"",m=ii(this._milliseconds)!==ii(h)?"-":"";return c+"P"+(r?f+r+"Y":"")+(o?f+o+"M":"")+(s?g+s+"D":"")+(l||u||d?"T":"")+(l?m+l+"H":"")+(u?m+u+"M":"")+(d?m+d+"S":"")}var ri=ze.prototype;return ri.isValid=function(){return this._isValid},ri.abs=function(){var t=this._data;return this._milliseconds=On(this._milliseconds),this._days=On(this._days),this._months=On(this._months),t.milliseconds=On(t.milliseconds),t.seconds=On(t.seconds),t.minutes=On(t.minutes),t.hours=On(t.hours),t.months=On(t.months),t.years=On(t.years),this},ri.add=function(t,e){return An(this,t,e,1)},ri.subtract=function(t,e){return An(this,t,e,-1)},ri.as=function(t){if(!this.isValid())return NaN;var e,n,i=this._milliseconds;if("month"===(t=R(t))||"quarter"===t||"year"===t)switch(e=this._days+i/864e5,n=this._months+In(e),t){case"month":return n;case"quarter":return n/3;case"year":return n/12}else switch(e=this._days+Math.round(Ln(this._months)),t){case"week":return e/7+i/6048e5;case"day":return e+i/864e5;case"hour":return 24*e+i/36e5;case"minute":return 1440*e+i/6e4;case"second":return 86400*e+i/1e3;case"millisecond":return Math.floor(864e5*e)+i;default:throw new Error("Unknown unit "+t)}},ri.asMilliseconds=Nn,ri.asSeconds=Wn,ri.asMinutes=Yn,ri.asHours=zn,ri.asDays=En,ri.asWeeks=Vn,ri.asMonths=Hn,ri.asQuarters=Bn,ri.asYears=jn,ri.valueOf=function(){return this.isValid()?this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*k(this._months/12):NaN},ri._bubble=function(){var t,e,n,i,a,r=this._milliseconds,o=this._days,s=this._months,l=this._data;return r>=0&&o>=0&&s>=0||r<=0&&o<=0&&s<=0||(r+=864e5*Fn(Ln(s)+o),o=0,s=0),l.milliseconds=r%1e3,t=w(r/1e3),l.seconds=t%60,e=w(t/60),l.minutes=e%60,n=w(e/60),l.hours=n%24,o+=w(n/24),a=w(In(o)),s+=a,o-=Fn(Ln(a)),i=w(s/12),s%=12,l.days=o,l.months=s,l.years=i,this},ri.clone=function(){return Xe(this)},ri.get=function(t){return t=R(t),this.isValid()?this[t+"s"]():NaN},ri.milliseconds=Gn,ri.seconds=qn,ri.minutes=Zn,ri.hours=$n,ri.days=Xn,ri.weeks=function(){return w(this.days()/7)},ri.months=Kn,ri.years=Jn,ri.humanize=function(t){if(!this.isValid())return this.localeData().invalidDate();var e=this.localeData(),n=function(t,e,n){var i=Xe(t).abs(),a=Qn(i.as("s")),r=Qn(i.as("m")),o=Qn(i.as("h")),s=Qn(i.as("d")),l=Qn(i.as("M")),u=Qn(i.as("y")),d=a<=ti.ss&&["s",a]||a0,d[4]=n,ei.apply(null,d)}(this,!t,e);return t&&(n=e.pastFuture(+this,n)),e.postformat(n)},ri.toISOString=ai,ri.toString=ai,ri.toJSON=ai,ri.locale=rn,ri.localeData=sn,ri.toIsoString=D("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",ai),ri.lang=on,j("X",0,0,"unix"),j("x",0,0,"valueOf"),dt("x",rt),dt("X",/[+-]?\d+(\.\d{1,3})?/),gt("X",(function(t,e,n){n._d=new Date(1e3*parseFloat(t,10))})),gt("x",(function(t,e,n){n._d=new Date(k(t))})),a.version="2.24.0",n=Le,a.fn=Mn,a.min=function(){return We("isBefore",[].slice.call(arguments,0))},a.max=function(){return We("isAfter",[].slice.call(arguments,0))},a.now=function(){return Date.now?Date.now():+new Date},a.utc=f,a.unix=function(t){return Le(1e3*t)},a.months=function(t,e){return Pn(t,e,"months")},a.isDate=u,a.locale=ge,a.invalid=p,a.duration=Xe,a.isMoment=_,a.weekdays=function(t,e,n){return Tn(t,e,n,"weekdays")},a.parseZone=function(){return Le.apply(null,arguments).parseZone()},a.localeData=pe,a.isDuration=Ee,a.monthsShort=function(t,e){return Pn(t,e,"monthsShort")},a.weekdaysMin=function(t,e,n){return Tn(t,e,n,"weekdaysMin")},a.defineLocale=me,a.updateLocale=function(t,e){if(null!=e){var n,i,a=ue;null!=(i=fe(t))&&(a=i._config),e=A(a,e),(n=new F(e)).parentLocale=de[t],de[t]=n,ge(t)}else null!=de[t]&&(null!=de[t].parentLocale?de[t]=de[t].parentLocale:null!=de[t]&&delete de[t]);return de[t]},a.locales=function(){return C(de)},a.weekdaysShort=function(t,e,n){return Tn(t,e,n,"weekdaysShort")},a.normalizeUnits=R,a.relativeTimeRounding=function(t){return void 0===t?Qn:"function"==typeof t&&(Qn=t,!0)},a.relativeTimeThreshold=function(t,e){return void 0!==ti[t]&&(void 0===e?ti[t]:(ti[t]=e,"s"===t&&(ti.ss=e-1),!0))},a.calendarFormat=function(t,e){var n=t.diff(e,"days",!0);return n<-6?"sameElse":n<-1?"lastWeek":n<0?"lastDay":n<1?"sameDay":n<2?"nextDay":n<7?"nextWeek":"sameElse"},a.prototype=Mn,a.HTML5_FMT={DATETIME_LOCAL:"YYYY-MM-DDTHH:mm",DATETIME_LOCAL_SECONDS:"YYYY-MM-DDTHH:mm:ss",DATETIME_LOCAL_MS:"YYYY-MM-DDTHH:mm:ss.SSS",DATE:"YYYY-MM-DD",TIME:"HH:mm",TIME_SECONDS:"HH:mm:ss",TIME_MS:"HH:mm:ss.SSS",WEEK:"GGGG-[W]WW",MONTH:"YYYY-MM"},a}()})),pi={datetime:"MMM D, YYYY, h:mm:ss a",millisecond:"h:mm:ss.SSS a",second:"h:mm:ss a",minute:"h:mm a",hour:"hA",day:"MMM D",week:"ll",month:"MMM YYYY",quarter:"[Q]Q - YYYY",year:"YYYY"};on._date.override("function"==typeof mi?{_id:"moment",formats:function(){return pi},parse:function(t,e){return"string"==typeof t&&"string"==typeof e?t=mi(t,e):t instanceof mi||(t=mi(t)),t.isValid()?t.valueOf():null},format:function(t,e){return mi(t).format(e)},add:function(t,e,n){return mi(t).add(e,n).valueOf()},diff:function(t,e,n){return mi(t).diff(mi(e),n)},startOf:function(t,e,n){return t=mi(t),"isoWeek"===e?t.isoWeekday(n).valueOf():t.startOf(e).valueOf()},endOf:function(t,e){return mi(t).endOf(e).valueOf()},_create:function(t){return mi(t)}}:{}),Y._set("global",{plugins:{filler:{propagate:!0}}});var vi={dataset:function(t){var e=t.fill,n=t.chart,i=n.getDatasetMeta(e),a=i&&n.isDatasetVisible(e)&&i.dataset._children||[],r=a.length||0;return r?function(t,e){return e=n)&&i;switch(r){case"bottom":return"start";case"top":return"end";case"zero":return"origin";case"origin":case"start":case"end":return r;default:return!1}}function yi(t){return(t.el._scale||{}).getPointPositionForValue?function(t){var e,n,i,a,r,o=t.el._scale,s=o.options,l=o.chart.data.labels.length,u=t.fill,d=[];if(!l)return null;for(e=s.ticks.reverse?o.max:o.min,n=s.ticks.reverse?o.min:o.max,i=o.getPointPositionForValue(0,e),a=0;a0;--r)B.canvas.lineTo(t,n[r],n[r-1],!0);else for(o=n[0].cx,s=n[0].cy,l=Math.sqrt(Math.pow(n[0].x-o,2)+Math.pow(n[0].y-s,2)),r=a-1;r>0;--r)t.arc(o,s,l,n[r].angle,n[r-1].angle,!0)}}function Mi(t,e,n,i,a,r){var o,s,l,u,d,h,c,f,g=e.length,m=i.spanGaps,p=[],v=[],b=0,y=0;for(t.beginPath(),o=0,s=g;o=0;--n)(e=l[n].$filler)&&e.visible&&(a=(i=e.el)._view,r=i._children||[],o=e.mapper,s=a.backgroundColor||Y.global.defaultColor,o&&s&&r.length&&(B.canvas.clipArea(u,t.chartArea),Mi(u,r,o,a,s,i._loop),B.canvas.unclipArea(u)))}},Di=B.rtl.getRtlAdapter,Ci=B.noop,Pi=B.valueOrDefault;function Ti(t,e){return t.usePointStyle&&t.boxWidth>e?e:t.boxWidth}Y._set("global",{legend:{display:!0,position:"top",align:"center",fullWidth:!0,reverse:!1,weight:1e3,onClick:function(t,e){var n=e.datasetIndex,i=this.chart,a=i.getDatasetMeta(n);a.hidden=null===a.hidden?!i.data.datasets[n].hidden:null,i.update()},onHover:null,onLeave:null,labels:{boxWidth:40,padding:10,generateLabels:function(t){var e=t.data.datasets,n=t.options.legend||{},i=n.labels&&n.labels.usePointStyle;return t._getSortedDatasetMetas().map((function(n){var a=n.controller.getStyle(i?0:void 0);return{text:e[n.index].label,fillStyle:a.backgroundColor,hidden:!t.isDatasetVisible(n.index),lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:a.borderWidth,strokeStyle:a.borderColor,pointStyle:a.pointStyle,rotation:a.rotation,datasetIndex:n.index}}),this)}}},legendCallback:function(t){var e,n,i,a=document.createElement("ul"),r=t.data.datasets;for(a.setAttribute("class",t.id+"-legend"),e=0,n=r.length;el.width)&&(h+=o+n.padding,d[d.length-(e>0?0:1)]=0),s[e]={left:0,top:0,width:i,height:o},d[d.length-1]+=i+n.padding})),l.height+=h}else{var c=n.padding,f=t.columnWidths=[],g=t.columnHeights=[],m=n.padding,p=0,v=0;B.each(t.legendItems,(function(t,e){var i=Ti(n,o)+o/2+a.measureText(t.text).width;e>0&&v+o+2*c>l.height&&(m+=p+n.padding,f.push(p),g.push(v),p=0,v=0),p=Math.max(p,i),v+=o+c,s[e]={left:0,top:0,width:i,height:o}})),m+=p,f.push(p),g.push(v),l.width+=m}t.width=l.width,t.height=l.height}else t.width=l.width=t.height=l.height=0},afterFit:Ci,isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},draw:function(){var t=this,e=t.options,n=e.labels,i=Y.global,a=i.defaultColor,r=i.elements.line,o=t.height,s=t.columnHeights,l=t.width,u=t.lineWidths;if(e.display){var d,h=Di(e.rtl,t.left,t.minSize.width),c=t.ctx,f=Pi(n.fontColor,i.defaultFontColor),g=B.options._parseFont(n),m=g.size;c.textAlign=h.textAlign("left"),c.textBaseline="middle",c.lineWidth=.5,c.strokeStyle=f,c.fillStyle=f,c.font=g.string;var p=Ti(n,m),v=t.legendHitBoxes,b=function(t,i){switch(e.align){case"start":return n.padding;case"end":return t-i;default:return(t-i+n.padding)/2}},y=t.isHorizontal();d=y?{x:t.left+b(l,u[0]),y:t.top+n.padding,line:0}:{x:t.left+n.padding,y:t.top+b(o,s[0]),line:0},B.rtl.overrideTextDirection(t.ctx,e.textDirection);var x=m+n.padding;B.each(t.legendItems,(function(e,i){var f=c.measureText(e.text).width,g=p+m/2+f,_=d.x,w=d.y;h.setWidth(t.minSize.width),y?i>0&&_+g+n.padding>t.left+t.minSize.width&&(w=d.y+=x,d.line++,_=d.x=t.left+b(l,u[d.line])):i>0&&w+x>t.top+t.minSize.height&&(_=d.x=_+t.columnWidths[d.line]+n.padding,d.line++,w=d.y=t.top+b(o,s[d.line]));var k=h.x(_);!function(t,e,i){if(!(isNaN(p)||p<=0)){c.save();var o=Pi(i.lineWidth,r.borderWidth);if(c.fillStyle=Pi(i.fillStyle,a),c.lineCap=Pi(i.lineCap,r.borderCapStyle),c.lineDashOffset=Pi(i.lineDashOffset,r.borderDashOffset),c.lineJoin=Pi(i.lineJoin,r.borderJoinStyle),c.lineWidth=o,c.strokeStyle=Pi(i.strokeStyle,a),c.setLineDash&&c.setLineDash(Pi(i.lineDash,r.borderDash)),n&&n.usePointStyle){var s=p*Math.SQRT2/2,l=h.xPlus(t,p/2),u=e+m/2;B.canvas.drawPoint(c,i.pointStyle,s,l,u,i.rotation)}else c.fillRect(h.leftForLtr(t,p),e,p,m),0!==o&&c.strokeRect(h.leftForLtr(t,p),e,p,m);c.restore()}}(k,w,e),v[i].left=h.leftForLtr(k,v[i].width),v[i].top=w,function(t,e,n,i){var a=m/2,r=h.xPlus(t,p+a),o=e+a;c.fillText(n.text,r,o),n.hidden&&(c.beginPath(),c.lineWidth=2,c.moveTo(r,o),c.lineTo(h.xPlus(r,i),o),c.stroke())}(k,w,e,f),y?d.x+=g+n.padding:d.y+=x})),B.rtl.restoreTextDirection(t.ctx,e.textDirection)}},_getLegendItemAt:function(t,e){var n,i,a,r=this;if(t>=r.left&&t<=r.right&&e>=r.top&&e<=r.bottom)for(a=r.legendHitBoxes,n=0;n=(i=a[n]).left&&t<=i.left+i.width&&e>=i.top&&e<=i.top+i.height)return r.legendItems[n];return null},handleEvent:function(t){var e,n=this,i=n.options,a="mouseup"===t.type?"click":t.type;if("mousemove"===a){if(!i.onHover&&!i.onLeave)return}else{if("click"!==a)return;if(!i.onClick)return}e=n._getLegendItemAt(t.x,t.y),"click"===a?e&&i.onClick&&i.onClick.call(n,t.native,e):(i.onLeave&&e!==n._hoveredItem&&(n._hoveredItem&&i.onLeave.call(n,t.native,n._hoveredItem),n._hoveredItem=e),i.onHover&&e&&i.onHover.call(n,t.native,e))}});function Ai(t,e){var n=new Oi({ctx:t.ctx,options:e,chart:t});pe.configure(t,n,e),pe.addBox(t,n),t.legend=n}var Fi={id:"legend",_element:Oi,beforeInit:function(t){var e=t.options.legend;e&&Ai(t,e)},beforeUpdate:function(t){var e=t.options.legend,n=t.legend;e?(B.mergeIf(e,Y.global.legend),n?(pe.configure(t,n,e),n.options=e):Ai(t,e)):n&&(pe.removeBox(t,n),delete t.legend)},afterEvent:function(t,e){var n=t.legend;n&&n.handleEvent(e)}},Ii=B.noop;Y._set("global",{title:{display:!1,fontStyle:"bold",fullWidth:!0,padding:10,position:"top",text:"",weight:2e3}});var Li=X.extend({initialize:function(t){B.extend(this,t),this.legendHitBoxes=[]},beforeUpdate:Ii,update:function(t,e,n){var i=this;return i.beforeUpdate(),i.maxWidth=t,i.maxHeight=e,i.margins=n,i.beforeSetDimensions(),i.setDimensions(),i.afterSetDimensions(),i.beforeBuildLabels(),i.buildLabels(),i.afterBuildLabels(),i.beforeFit(),i.fit(),i.afterFit(),i.afterUpdate(),i.minSize},afterUpdate:Ii,beforeSetDimensions:Ii,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:Ii,beforeBuildLabels:Ii,buildLabels:Ii,afterBuildLabels:Ii,beforeFit:Ii,fit:function(){var t,e=this,n=e.options,i=e.minSize={},a=e.isHorizontal();n.display?(t=(B.isArray(n.text)?n.text.length:1)*B.options._parseFont(n).lineHeight+2*n.padding,e.width=i.width=a?e.maxWidth:t,e.height=i.height=a?t:e.maxHeight):e.width=i.width=e.height=i.height=0},afterFit:Ii,isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},draw:function(){var t=this,e=t.ctx,n=t.options;if(n.display){var i,a,r,o=B.options._parseFont(n),s=o.lineHeight,l=s/2+n.padding,u=0,d=t.top,h=t.left,c=t.bottom,f=t.right;e.fillStyle=B.valueOrDefault(n.fontColor,Y.global.defaultFontColor),e.font=o.string,t.isHorizontal()?(a=h+(f-h)/2,r=d+l,i=f-h):(a="left"===n.position?h+l:f-l,r=d+(c-d)/2,i=c-d,u=Math.PI*("left"===n.position?-.5:.5)),e.save(),e.translate(a,r),e.rotate(u),e.textAlign="center",e.textBaseline="middle";var g=n.text;if(B.isArray(g))for(var m=0,p=0;p=0;i--){var a=t[i];if(e(a))return a}},B.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},B.almostEquals=function(t,e,n){return Math.abs(t-e)=t},B.max=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.max(t,e)}),Number.NEGATIVE_INFINITY)},B.min=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.min(t,e)}),Number.POSITIVE_INFINITY)},B.sign=Math.sign?function(t){return Math.sign(t)}:function(t){return 0===(t=+t)||isNaN(t)?t:t>0?1:-1},B.toRadians=function(t){return t*(Math.PI/180)},B.toDegrees=function(t){return t*(180/Math.PI)},B._decimalPlaces=function(t){if(B.isFinite(t)){for(var e=1,n=0;Math.round(t*e)/e!==t;)e*=10,n++;return n}},B.getAngleFromPoint=function(t,e){var n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),r=Math.atan2(i,n);return r<-.5*Math.PI&&(r+=2*Math.PI),{angle:r,distance:a}},B.distanceBetweenPoints=function(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))},B.aliasPixel=function(t){return t%2==0?0:.5},B._alignPixel=function(t,e,n){var i=t.currentDevicePixelRatio,a=n/2;return Math.round((e-a)*i)/i+a},B.splineCurve=function(t,e,n,i){var a=t.skip?e:t,r=e,o=n.skip?e:n,s=Math.sqrt(Math.pow(r.x-a.x,2)+Math.pow(r.y-a.y,2)),l=Math.sqrt(Math.pow(o.x-r.x,2)+Math.pow(o.y-r.y,2)),u=s/(s+l),d=l/(s+l),h=i*(u=isNaN(u)?0:u),c=i*(d=isNaN(d)?0:d);return{previous:{x:r.x-h*(o.x-a.x),y:r.y-h*(o.y-a.y)},next:{x:r.x+c*(o.x-a.x),y:r.y+c*(o.y-a.y)}}},B.EPSILON=Number.EPSILON||1e-14,B.splineCurveMonotone=function(t){var e,n,i,a,r,o,s,l,u,d=(t||[]).map((function(t){return{model:t._model,deltaK:0,mK:0}})),h=d.length;for(e=0;e0?d[e-1]:null,(a=e0?d[e-1]:null,a=e=t.length-1?t[0]:t[e+1]:e>=t.length-1?t[t.length-1]:t[e+1]},B.previousItem=function(t,e,n){return n?e<=0?t[t.length-1]:t[e-1]:e<=0?t[0]:t[e-1]},B.niceNum=function(t,e){var n=Math.floor(B.log10(t)),i=t/Math.pow(10,n);return(e?i<1.5?1:i<3?2:i<7?5:10:i<=1?1:i<=2?2:i<=5?5:10)*Math.pow(10,n)},B.requestAnimFrame="undefined"==typeof window?function(t){t()}:window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)},B.getRelativePosition=function(t,e){var n,i,a=t.originalEvent||t,r=t.target||t.srcElement,o=r.getBoundingClientRect(),s=a.touches;s&&s.length>0?(n=s[0].clientX,i=s[0].clientY):(n=a.clientX,i=a.clientY);var l=parseFloat(B.getStyle(r,"padding-left")),u=parseFloat(B.getStyle(r,"padding-top")),d=parseFloat(B.getStyle(r,"padding-right")),h=parseFloat(B.getStyle(r,"padding-bottom")),c=o.right-o.left-l-d,f=o.bottom-o.top-u-h;return{x:n=Math.round((n-o.left-l)/c*r.width/e.currentDevicePixelRatio),y:i=Math.round((i-o.top-u)/f*r.height/e.currentDevicePixelRatio)}},B.getConstraintWidth=function(t){return n(t,"max-width","clientWidth")},B.getConstraintHeight=function(t){return n(t,"max-height","clientHeight")},B._calculatePadding=function(t,e,n){return(e=B.getStyle(t,e)).indexOf("%")>-1?n*parseInt(e,10)/100:parseInt(e,10)},B._getParentNode=function(t){var e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e},B.getMaximumWidth=function(t){var e=B._getParentNode(t);if(!e)return t.clientWidth;var n=e.clientWidth,i=n-B._calculatePadding(e,"padding-left",n)-B._calculatePadding(e,"padding-right",n),a=B.getConstraintWidth(t);return isNaN(a)?i:Math.min(i,a)},B.getMaximumHeight=function(t){var e=B._getParentNode(t);if(!e)return t.clientHeight;var n=e.clientHeight,i=n-B._calculatePadding(e,"padding-top",n)-B._calculatePadding(e,"padding-bottom",n),a=B.getConstraintHeight(t);return isNaN(a)?i:Math.min(i,a)},B.getStyle=function(t,e){return t.currentStyle?t.currentStyle[e]:document.defaultView.getComputedStyle(t,null).getPropertyValue(e)},B.retinaScale=function(t,e){var n=t.currentDevicePixelRatio=e||"undefined"!=typeof window&&window.devicePixelRatio||1;if(1!==n){var i=t.canvas,a=t.height,r=t.width;i.height=a*n,i.width=r*n,t.ctx.scale(n,n),i.style.height||i.style.width||(i.style.height=a+"px",i.style.width=r+"px")}},B.fontString=function(t,e,n){return e+" "+t+"px "+n},B.longestText=function(t,e,n,i){var a=(i=i||{}).data=i.data||{},r=i.garbageCollect=i.garbageCollect||[];i.font!==e&&(a=i.data={},r=i.garbageCollect=[],i.font=e),t.font=e;var o,s,l,u,d,h=0,c=n.length;for(o=0;on.length){for(o=0;oi&&(i=r),i},B.numberOfLabelLines=function(t){var e=1;return B.each(t,(function(t){B.isArray(t)&&t.length>e&&(e=t.length)})),e},B.color=w?function(t){return t instanceof CanvasGradient&&(t=Y.global.defaultColor),w(t)}:function(t){return console.error("Color.js not found!"),t},B.getHoverColor=function(t){return t instanceof CanvasPattern||t instanceof CanvasGradient?t:B.color(t).saturate(.5).darken(.1).rgbString()}}(),nn._adapters=on,nn.Animation=J,nn.animationService=Q,nn.controllers=Qt,nn.DatasetController=at,nn.defaults=Y,nn.Element=X,nn.elements=kt,nn.Interaction=oe,nn.layouts=pe,nn.platform=Le,nn.plugins=Re,nn.Scale=_n,nn.scaleService=Ne,nn.Ticks=sn,nn.Tooltip=qe,nn.helpers.each(gi,(function(t,e){nn.scaleService.registerScaleType(e,t,t._defaults)})),Ni)Ni.hasOwnProperty(Ei)&&nn.plugins.register(Ni[Ei]);nn.platform.initialize();var Vi=nn;return"undefined"!=typeof window&&(window.Chart=nn),nn.Chart=nn,nn.Legend=Ni.legend._element,nn.Title=Ni.title._element,nn.pluginService=nn.plugins,nn.PluginBase=nn.Element.extend({}),nn.canvasHelpers=nn.helpers.canvas,nn.layoutService=nn.layouts,nn.LinearScaleBase=Cn,nn.helpers.each(["Bar","Bubble","Doughnut","Line","PolarArea","Radar","Scatter"],(function(t){nn[t]=function(e,n){return new nn(e,nn.helpers.merge(n||{},{type:t.charAt(0).toLowerCase()+t.slice(1)}))}})),Vi})); diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..51ca51c Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/main.css b/assets/main.css new file mode 100644 index 0000000..ba43f85 --- /dev/null +++ b/assets/main.css @@ -0,0 +1,399 @@ +/* Constants */ + +:root { + /* Colors */ + --color-red: #CF5C56; + --color-brown: #A04642; + --color-green: #9FD2A5; + --color-blue: #6CA2A4; + --color-gray: #EEEEEE; + --color-yellow: #FFF09E; + + /* Sizes */ + --size-bee: 0.2rem; + --size-mouse: 0.5rem; + --size-cat: 0.75rem; + --size-dog: 1rem; + --size-lion: 1.25rem; + --size-bear: 1.5rem; + --size-horse: 2rem; + --size-camel: 2.625rem; + --size-rhino: 3.375rem; + --size-elephant: 4.25rem; + --size-mammoth: 5.25rem; +} + +/* Reset */ + +html { box-sizing: border-box; } +*, *:before, *:after { box-sizing: inherit; } +body { margin: 0; } +p { margin: 0; } +a { color: inherit; text-decoration: none; } +h1 { font-size: inherit; font-weight: normal; margin: 0; } +select { height: inherit; font-size: inherit; } +input { color: inherit; font-size: inherit; } +ul { list-style-type: none; margin: 0; padding-left: 0; } + +/* Media */ + +@media screen and (max-width: 500px) { + html { font-size: 14px; } + .g-Media__Large { + display: none !important; + } +} + +@media screen and (min-width: 500px) and (max-width: 900px) { + html { font-size: 16px; } +} + +@media screen and (min-width: 900px) { + html { font-size: 18px; } +} + +/* Layout */ + +body { + display: flex; + flex-direction: column; + align-items: center; +} + +.g-Header { + width: 100%; +} + +.g-Header__Primary { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-cat) var(--size-dog); +} + +.g-Header__Title { + display: block; + font-size: var(--size-horse); + color: var(--color-red); +} + +.g-Header__Logout { + cursor: pointer; + margin-left: var(--size-cat); + background-color: transparent; + border: none; +} + +.g-Header__Logout:hover { + text-decoration: underline; +} + +.g-Header__Logout:focus { + text-decoration: underline; +} + +.g-Header__Secondary { + display: flex; + background-color: var(--color-red); + color: white; + overflow-y: auto; +} + +.g-Header__Link { + padding: var(--size-mouse) var(--size-dog); +} + +.g-Header__Link:hover { + background-color: var(--color-brown); +} + +.g-Header__Link:focus { + background-color: var(--color-brown); +} + +.g-Header__Link--Current { + background-color: var(--color-brown); +} + +.g-Main { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: 0 var(--size-bear); + margin: var(--size-horse) 0; + overflow-x: hidden; +} + +.g-Title { + font-size: var(--size-lion); + color: var(--color-red); + margin-bottom: var(--size-lion); + text-decoration: underline; +} + +.g-Paragraph { + margin-bottom: var(--size-lion); +} + +/* Heading */ + +.g-H1 { + font-size: var(--size-lion); + text-decoration: underline; + margin-bottom: var(--size-lion); +} + +/* Link */ + +.g-Link { + color: var(--color-blue); +} + +.g-Link:hover { + text-decoration: underline; +} + +/* Table */ + +.g-Table { + display: table; + margin: 0 auto var(--size-bear); +} + +.g-Table__Row { + display: table-row; + text-decoration: none; + color: inherit; +} + +.g-Table__Row--Highlight { + background-color: var(--color-yellow); +} + +.g-Table__Row:not(.g-Table__Row--Header):not(.g-Table__Row--Highlight):hover { + background-color: var(--color-gray); +} + +.g-Table__Row--Header { + font-weight: bold; +} + +.g-Table__Cell { + display: table-cell; + padding: var(--size-mouse) var(--size-dog); + white-space: nowrap; +} + +.g-Table__Cell:first-child { + padding-left: 0; +} + +.g-Table__Cell:last-child { + padding-right: 0; +} + +.g-Table__NumericCell { + text-align: right; +} + +/* Paging */ + +.g-Paging { + display: flex; + justify-content: center; +} + +.g-Paging__Link { + padding: 0 var(--size-dog); +} + +.g-Paging__Link--Active { + color: var(--color-blue); +} + +/* Form */ + +.g-Form { + display: flex; + flex-direction: column; + margin-bottom: var(--size-lion); +} + +@media screen and (min-width: 500px) { + .g-Form { + width: 450px; + } +} + + +.g-Form__Label { + margin-bottom: var(--size-mouse); +} + +.g-Form__Input { + margin-bottom: var(--size-lion); + background-color: white; + border: 1px solid black; + height: var(--size-camel); + padding: 0 var(--size-cat); + width: 100%; +} + +.g-Form__InputColor { + padding: 0; +} + +.g-Form__Select { + margin-bottom: var(--size-lion); + background-color: white; + border: 1px solid black; + height: var(--size-camel); + padding: 0 var(--size-cat); + width: 100%; +} + +.g-Form__Error { + color: var(--color-red); + text-align: center; + margin-bottom: var(--size-bear); +} + +/* Buttons */ + +.g-Button__Search { + cursor: pointer; + background-color: var(--color-gray); + color: white; + border: none; + height: var(--size-camel); + padding: 0 var(--size-dog); + border-top: 1px solid black; + border-right: 1px solid black; + border-bottom: 1px solid black; +} + +.g-Button__Validate { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background-color: var(--color-green); + color: white; + border: none; + height: var(--size-camel); + padding: 0 var(--size-dog); + border-radius: var(--size-bee); +} + +.g-Button__Validate:hover { + text-decoration: underline; +} + +.g-Button__Danger { + cursor: pointer; + background-color: var(--color-red); + color: white; + border: none; + height: var(--size-camel); + padding: 0 var(--size-dog); + border-radius: var(--size-bee); +} + +.g-Button__Danger:hover { + text-decoration: underline; +} + +.g-Button__Danger:disabled { + background-color: var(--color-gray); + cursor: pointer; +} + +/* Login */ + +.g-Login { + margin-top: var(--size-elephant); +} + +.g-Login__Button { + width: 100%; +} + +/* Payment */ + +.g-Payments__NoResults { + margin-top: var(--size-camel); + text-align: center; +} + +.g-Payments__Header { + display: flex; + justify-content: space-between; + align-items: center; +} + +@media screen and (max-width: 500px) { + .g-Payments__Header { + flex-direction: column; + } +} + +.g-Payments__FrequenciesAndSearch { + display: flex; + align-items: center; +} + +.g-Payments__Search { + display: flex; + margin: 0 var(--size-bear) 0 0; +} + +.g-Payments__SearchInput { + margin-bottom: 0; + width: 150px; +} + +.g-Payments__Frequencies { + display: flex; + margin: 0 var(--size-dog); +} + +.g-Payments__Frequency { + margin: 0 var(--size-mouse); +} + +.g-Payments__Frequency--Selected { + color: var(--color-red); + font-weight: bold; +} + +@media screen and (max-width: 500px) { + .g-Payments__New { + margin-top: var(--size-dog); + } +} + +.g-Payments__Refund { + color: var(--color-green); +} + +/* Balance */ + +.g-Balance__ExceedingPayers { + background-color: var(--color-green); + border-radius: var(--size-bee); + color: white; + margin-bottom: var(--size-bear); + text-align: center; +} + +.g-Balance__ExceedingPayer { + padding: var(--size-mouse); +} + +/* Chart */ + +.g-Chart { + width: 80%; + margin: 0 auto; +} diff --git a/assets/main.js b/assets/main.js new file mode 100644 index 0000000..ba82a9f --- /dev/null +++ b/assets/main.js @@ -0,0 +1,226 @@ +const path = window.location.pathname + +// Setting up interactivity according to the current page + +if (path == '/login') { + + trim_inputs_on_blur() + +} else if (path == '/payment') { // Payment creation + + trim_inputs_on_blur() + auto_fill_category() + +} else if (path.startsWith('/payment/')) { // Payment modification + + trim_inputs_on_blur() + auto_fill_category() + control_remove_button() + +} else if (path == '/income') { // Income creation + + trim_inputs_on_blur() + +} else if (path.startsWith('/income/')) { // Income modification + + trim_inputs_on_blur() + control_remove_button() + +} else if (path == '/category') { // Category creation + + trim_inputs_on_blur() + +} else if (path.startsWith('/category/')) { // Category modification + + trim_inputs_on_blur() + control_remove_button() + +} else if (path == '/balance') { + +} else if (path == '/statistics') { + + show_statistics() + +} + +// Functions + +function trim_inputs_on_blur() { + document.querySelectorAll('input').forEach(function (input) { + input.addEventListener('blur', function () { + input.value = input.value.trim() + }) + }) +} + +function control_remove_button() { + const removeInput = document.getElementById('remove-input') + const removeButton = document.getElementById('remove-button') + + if (removeInput && removeButton) { + removeInput.addEventListener('input', function() { + if (removeInput.value.trim() == removeInput.getAttribute('data-name')) { + removeButton.removeAttribute('disabled') + } else { + removeButton.setAttribute('disabled', true) + } + }) + } +} + +function auto_fill_category() { + const name = document.getElementById('name') + const category = document.getElementById('category_id') + + function onNameChange() { + const query = name.value.trim() + if (query) { + const xhttp = new XMLHttpRequest() + xhttp.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + category.value = this.responseText + } + } + xhttp.open('GET', `/payment/category?payment_name=${query}`, true) + xhttp.send() + } + } + + name.addEventListener('input', debounce(onNameChange, 500)) +} + +function show_statistics() { + const categories = JSON.parse(document.getElementById('categories').textContent) + const incomes = JSON.parse(document.getElementById('incomes').textContent) + const payments = JSON.parse(document.getElementById('payments').textContent) + + const dates = incomes.map(function (i) { return i.date }) + + const datasets = [ + { + label: 'Revenus', + data: incomes.map(function (i) { return i.amount }), + fill: false, + backgroundColor: '#222222', + borderColor: '#222222' + } + ] + + const total_payments = {}; + const categories_payments = {} + payments.forEach(function (p) { + if (categories_payments[p.category_id] === undefined) { + categories_payments[p.category_id] = {} + } + categories_payments[p.category_id][p.start_date] = p.cost + + if (total_payments[p.start_date] === undefined) { + total_payments[p.start_date] = 0 + } + total_payments[p.start_date] += p.cost + }) + + datasets.push({ + label: 'Total des paiements', + data: dates.map(function (d) { return total_payments[d] || 0 }), + fill: false, + backgroundColor: '#555555', + borderColor: '#555555' + }) + + Object.keys(categories_payments).forEach(function (category_id) { + const category_payments = categories_payments[category_id] + const category = categories.find(function (c) { return c.id == category_id }) + datasets.push({ + label: category.name, + data: dates.map(function (d) { return category_payments[d] || 0 }), + fill: false, + backgroundColor: category.color, + borderColor: category.color, + }) + }) + + const chart = new Chart(document.getElementById('g-Chart__Canvas').getContext('2d'), { + type: 'line', + + data: { + labels: dates, + datasets + }, + + options: { + responsive: true, + tooltips: { + mode: 'nearest', + intersect: false, + callbacks: { + title: function(tooltipItem, data) { + return capitalize(prettyPrintMonth(tooltipItem[0].xLabel)) + }, + label: function(tooltipItem, data) { + let label = data.datasets[tooltipItem.datasetIndex].label || '' + if (label) { + label += ': ' + } + label += `${tooltipItem.yLabel} €` + return label + } + } + }, + hover: { + mode: 'nearest', + intersect: true + }, + scales: { + xAxes: [ + { + ticks: { + callback: prettyPrintMonth + } + } + ], + yAxes: [ + { + ticks: { + beginAtZero: true, + callback: function(value) { + return `${value} €` + } + } + } + ] + } + } + }) +} + +function debounce(callback, delay) { + let timeout + return function() { + clearTimeout(timeout) + timeout = setTimeout(callback, delay) + } +} + +function prettyPrintMonth(isoDate) { + xs = isoDate.split('-') + months = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre' + ] + return `${months[parseInt(xs[1]) - 1]} ${xs[0]}` +} + +function capitalize(str) { + return str.replace(/^\w/, function (c) { return c.toUpperCase() }) +} diff --git a/bin/db b/bin/db new file mode 100755 index 0000000..431ba78 --- /dev/null +++ b/bin/db @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +cd $(dirname "$0")/.. + +DB_PATH="database.db" + +if [ "$1" == "init" ]; then + + if [ -f "$DB_PATH" ]; then + rm "$DB_PATH" + fi + + for MIGRATION in $(ls sql/migrations); do + printf "\n- Applying sql/migrations/$MIGRATION\n\n" + sqlite3 database.db < "sql/migrations/$MIGRATION" + done + + printf "\n- Applying sql/fixtures.sql\n\n" + sqlite3 database.db < sql/fixtures.sql + +else + + echo "Usage: $0 init" + exit 1 + +fi diff --git a/bin/watch b/bin/watch new file mode 100755 index 0000000..84363fb --- /dev/null +++ b/bin/watch @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +cd $(dirname "$0")/.. + +CMD=${1:-check} + +RUST_LOG=budget=info cargo-watch \ + --ignore README.md \ + --ignore bin \ + --ignore sql \ + --ignore database.db \ + --ignore database.db-shm \ + --ignore database.db-wal \ + --clear \ + -x "$CMD" diff --git a/cabal-client.project b/cabal-client.project deleted file mode 100644 index 182ead2..0000000 --- a/cabal-client.project +++ /dev/null @@ -1,4 +0,0 @@ -compiler: ghcjs -packages: - common/ - client/ diff --git a/cabal-server.project b/cabal-server.project deleted file mode 100644 index 0ce5568..0000000 --- a/cabal-server.project +++ /dev/null @@ -1,3 +0,0 @@ -packages: - common/ - server/ diff --git a/client/LICENSE b/client/LICENSE deleted file mode 100644 index 45644ff..0000000 --- a/client/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/client/Setup.hs b/client/Setup.hs deleted file mode 100644 index 4467109..0000000 --- a/client/Setup.hs +++ /dev/null @@ -1,2 +0,0 @@ -import Distribution.Simple -main = defaultMain diff --git a/client/client.cabal b/client/client.cabal deleted file mode 100644 index cf2c5a1..0000000 --- a/client/client.cabal +++ /dev/null @@ -1,90 +0,0 @@ -Name: client -Version: 0.0.1 -License: GPL-3 -License-file: LICENSE -Author: Joris Guyonvarch -Maintainer: joris@guyonvarch.me -Category: Web -Build-type: Simple -Cabal-version: >=1.10 - -Executable client - Main-Is: Main.hs - -- Ghc-options: -Wall -Werror - Hs-source-dirs: src - Default-language: Haskell2010 - - Default-extensions: - ExistentialQuantification - LambdaCase - MultiParamTypeClasses - OverloadedStrings - RecursiveDo - ScopedTypeVariables - - Build-depends: - aeson - , base >= 4.11 && < 5 - , bytestring - , common - , containers - , data-default - , ghcjs-dom-jsffi - , jsaddle-dom - , reflex-dom - , text - , time - , validation - - -- Router - , ghcjs-base - , ghcjs-prim - , ghcjs-dom - , jsaddle - , lens - , uri-bytestring - - other-modules: - Component.Appearing - Component.Button - Component.ConfirmDialog - Component.Form - Component.Input - Component.Link - Component.Modal - Component.ModalForm - Component.Pages - Component.Select - Component.Table - Component.Tag - Loadable - Model.Route - Util.Ajax - Util.Css - Util.Either - Util.Reflex - Util.Router - Util.Validation - Util.WaitFor - View.App - View.Header - View.Icon - View.Income.Form - View.Income.Header - View.Income.Income - View.Income.Reducer - View.Income.Table - View.Category.Form - View.Category.Category - View.Category.Reducer - View.Category.Table - View.NotFound - View.Payment.Form - View.Payment.HeaderForm - View.Payment.HeaderInfos - View.Payment.Payment - View.Payment.Reducer - View.Payment.Table - View.SignIn - View.Statistics.Chart - View.Statistics.Statistics diff --git a/client/src/Component/Appearing.hs b/client/src/Component/Appearing.hs deleted file mode 100644 index e0144ca..0000000 --- a/client/src/Component/Appearing.hs +++ /dev/null @@ -1,10 +0,0 @@ -module Component.Appearing - ( view - ) where - -import Reflex.Dom (MonadWidget) -import qualified Reflex.Dom as R - -view :: forall t m a. MonadWidget t m => m a -> m a -view = - R.divClass "g-Appearing" diff --git a/client/src/Component/Button.hs b/client/src/Component/Button.hs deleted file mode 100644 index 153a61b..0000000 --- a/client/src/Component/Button.hs +++ /dev/null @@ -1,57 +0,0 @@ -module Component.Button - ( In(..) - , Out(..) - , view - , defaultIn - ) where - -import qualified Data.Map as M -import Data.Maybe (catMaybes) -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import qualified View.Icon as Icon - -data In t m = In - { _in_class :: Dynamic t Text - , _in_content :: m () - , _in_waiting :: Event t Bool - , _in_tabIndex :: Maybe Int - , _in_submit :: Bool - } - -defaultIn :: forall t m. MonadWidget t m => m () -> In t m -defaultIn content = In - { _in_class = R.constDyn "" - , _in_content = content - , _in_waiting = R.never - , _in_tabIndex = Nothing - , _in_submit = False - } - -data Out t = Out - { _out_clic :: Event t () - } - -view :: forall t m. MonadWidget t m => In t m -> m (Out t) -view input = do - dynWaiting <- R.holdDyn False $ _in_waiting input - - let attr = do - buttonClass <- _in_class input - waiting <- dynWaiting - return . M.fromList . catMaybes $ - [ Just ("type", if _in_submit input then "submit" else "button") - , (\i -> ("tabindex", T.pack . show $ i)) <$> _in_tabIndex input - , Just ("class", T.intercalate " " [ buttonClass, if waiting then "waiting" else "" ]) - ] - - (e, _) <- R.elDynAttr' "button" attr $ do - Icon.loading - R.divClass "content" $ _in_content input - - return $ Out - { _out_clic = R.domEvent R.Click e - } diff --git a/client/src/Component/ConfirmDialog.hs b/client/src/Component/ConfirmDialog.hs deleted file mode 100644 index cf26593..0000000 --- a/client/src/Component/ConfirmDialog.hs +++ /dev/null @@ -1,49 +0,0 @@ -module Component.ConfirmDialog - ( In(..) - , view - ) where - -import Data.Text (Text) -import Reflex.Dom (Event, MonadWidget) -import qualified Reflex.Dom as R - -import qualified Common.Msg as Msg -import qualified Component.Button as Button -import qualified Component.Modal as Modal -import qualified Util.Either as EitherUtil -import qualified Util.WaitFor as WaitFor - -data In t m = In - { _in_header :: Text - , _in_confirm :: Event t () -> m (Event t ()) - } - -view :: forall t m a. MonadWidget t m => (In t m) -> Modal.Content t m -view input _ = - R.divClass "confirm" $ do - R.divClass "confirmHeader" $ - R.text $ _in_header input - - R.divClass "confirmContent" $ do - (confirm, cancel) <- R.divClass "buttons" $ do - - cancel <- Button._out_clic <$> (Button.view $ - (Button.defaultIn (R.text $ Msg.get Msg.Dialog_Undo)) - { Button._in_class = R.constDyn "undo" }) - - rec - confirm <- Button._out_clic <$> (Button.view $ - (Button.defaultIn (R.text $ Msg.get Msg.Dialog_Confirm)) - { Button._in_class = R.constDyn "confirm" - , Button._in_submit = True - , Button._in_waiting = waiting - }) - - (result, waiting) <- WaitFor.waitFor (_in_confirm input) confirm - - return (result, cancel) - - return $ - ( R.leftmost [ cancel, () <$ confirm ] - , confirm - ) diff --git a/client/src/Component/Form.hs b/client/src/Component/Form.hs deleted file mode 100644 index 6878e68..0000000 --- a/client/src/Component/Form.hs +++ /dev/null @@ -1,12 +0,0 @@ -module Component.Form - ( view - ) where - -import qualified Data.Map as M -import Reflex.Dom (MonadWidget) -import qualified Reflex.Dom as R - -view :: forall t m a. MonadWidget t m => m a -> m a -view content = - R.elAttr "form" (M.singleton "onsubmit" "event.preventDefault()") $ - content diff --git a/client/src/Component/Input.hs b/client/src/Component/Input.hs deleted file mode 100644 index bcff377..0000000 --- a/client/src/Component/Input.hs +++ /dev/null @@ -1,151 +0,0 @@ -module Component.Input - ( In(..) - , Out(..) - , view - , defaultIn - ) where - -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time (NominalDiffTime) -import Data.Validation (Validation (Failure, Success)) -import qualified Data.Validation as V -import Reflex.Dom (Dynamic, Event, MonadWidget, Reflex, - (&), (.~)) -import qualified Reflex.Dom as R - -import qualified Common.Util.Validation as ValidationUtil -import qualified Component.Button as Button -import qualified View.Icon as Icon - -data In a = In - { _in_hasResetButton :: Bool - , _in_label :: Text - , _in_initialValue :: Text - , _in_inputType :: Text - , _in_validation :: Text -> Validation Text a - } - -defaultIn :: In Text -defaultIn = In - { _in_hasResetButton = True - , _in_label = "" - , _in_initialValue = "" - , _in_inputType = "text" - , _in_validation = V.Success - } - -data Out t a = Out - { _out_raw :: Dynamic t Text - , _out_value :: Dynamic t (Validation Text a) - , _out_enter :: Event t () - } - -view - :: forall t m a b. MonadWidget t m - => In a - -> Event t Text -- reset - -> Event t b -- validate - -> m (Out t a) -view input reset validate = do - rec - let resetValue = R.leftmost - [ reset - , fmap (const "") resetClic - ] - - inputAttr = R.ffor value (\v -> - if T.null v && _in_inputType input /= "date" && _in_inputType input /= "color" - then M.empty - else M.singleton "class" "filled") - - value = R._textInput_value textInput - - containerAttr = R.ffor inputError (\e -> - M.singleton "class" $ T.intercalate " " - [ "textInput" - , if Maybe.isJust e then "error" else "" - ]) - - let valueWithValidation = R.ffor value (\v -> (v, _in_validation input $ v)) - inputError <- getInputError valueWithValidation validate - - (textInput, resetClic) <- R.elDynAttr "div" containerAttr $ do - - textInput <- R.el "label" $ do - textInput <- R.textInput $ R.def - & R.attributes .~ inputAttr - & R.setValue .~ resetValue - & R.textInputConfig_initialValue .~ (_in_initialValue input) - & R.textInputConfig_inputType .~ (_in_inputType input) - - R.divClass "label" $ - R.text (_in_label input) - - return textInput - - resetClic <- - if _in_hasResetButton input - then - Button._out_clic <$> (Button.view $ - (Button.defaultIn Icon.cross) - { Button._in_class = R.constDyn "reset" - , Button._in_tabIndex = Just (-1) - }) - else - return R.never - - R.divClass "errorMessage" $ - R.dynText . fmap (Maybe.fromMaybe "") $ inputError - - return (textInput, resetClic) - - let enter = fmap (const ()) $ R.ffilter ((==) 13) . R._textInput_keypress $ textInput - - return $ Out - { _out_raw = value - , _out_value = fmap snd valueWithValidation - , _out_enter = enter - } - -getInputError - :: forall t m a b c. MonadWidget t m - => Dynamic t (Text, Validation Text a) - -> Event t c - -> m (Dynamic t (Maybe Text)) -getInputError validatedValue validate = do - let errorDynamic = fmap (\(t, v) -> (t, validationError v)) validatedValue - errorEvent = R.updated errorDynamic - delayedError <- R.debounce (1 :: NominalDiffTime) errorEvent - fmap (fmap fst) $ R.foldDyn - (\event (err, hasBeenResetted) -> - case event of - ModifiedEvent t -> - (Nothing, T.null t) - - ValidateEvent e -> - (e, False) - - DelayEvent e -> - if hasBeenResetted then - (Nothing, False) - else - (e, False) - ) - (Nothing, False) - (R.leftmost - [ fmap (\(t, _) -> ModifiedEvent t) errorEvent - , fmap (\(_, e) -> DelayEvent e) delayedError - , R.attachWith (\(_, e) _ -> ValidateEvent e) (R.current errorDynamic) validate - ]) - -validationError :: (Validation Text a) -> Maybe Text -validationError (Failure e) = Just e -validationError _ = Nothing - -data InputEvent - = ModifiedEvent Text - | DelayEvent (Maybe Text) - | ValidateEvent (Maybe Text) diff --git a/client/src/Component/Link.hs b/client/src/Component/Link.hs deleted file mode 100644 index 1fd620e..0000000 --- a/client/src/Component/Link.hs +++ /dev/null @@ -1,33 +0,0 @@ -module Component.Link - ( view - ) where - -import Data.Map (Map) -import qualified Data.Map as M -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, MonadWidget) -import qualified Reflex.Dom as R - -view :: forall t m a. MonadWidget t m => Text -> Dynamic t (Map Text Text) -> Text -> m () -view href inputAttrs content = - R.elDynAttr "a" attrs (R.text content) - where - - onclickHandler = - T.intercalate ";" - [ "history.pushState(0, '', event.target.href)" - , "dispatchEvent(new PopStateEvent('popstate', {cancelable: true, bubbles: true, view: window}))" - , "return false" - ] - - attrs = - R.ffor inputAttrs (\as -> - (M.union - (M.fromList - [ ("onclick", onclickHandler) - , ("href", href) - ] - ) - as) - ) diff --git a/client/src/Component/Modal.hs b/client/src/Component/Modal.hs deleted file mode 100644 index 46d3f64..0000000 --- a/client/src/Component/Modal.hs +++ /dev/null @@ -1,117 +0,0 @@ -module Component.Modal - ( In(..) - , Content - , view - ) where - -import Control.Monad (void) -import qualified Data.Map as M -import qualified Data.Map.Lazy as LM -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Clock (NominalDiffTime) -import qualified GHCJS.DOM.Element as Element -import qualified GHCJS.DOM.Node as Node -import JSDOM.Types (JSString) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R -import qualified Reflex.Dom.Class as R - -import qualified Util.Reflex as ReflexUtil - --- Content = CurtainClickEvent -> (CancelEvent, ConfirmEvent) -type Content t m = Event t () -> m (Event t (), Event t ()) - -data In t m = In - { _in_show :: Event t () - , _in_content :: Content t m - } - -view :: forall t m a. MonadWidget t m => In t m -> m (Event t ()) -view input = do - rec - let show = Show <$ (_in_show input) - - startHiding = - R.attachWithMaybe - (\a _ -> if a then Just StartHiding else Nothing) - (R.current canBeHidden) - (R.leftmost [ hide, curtainClick ]) - - canBeHidden <- - R.holdDyn True $ R.leftmost - [ False <$ startHiding - , True <$ endHiding - ] - - endHiding <- - R.delay (0.2 :: NominalDiffTime) (EndHiding <$ startHiding) - - let action = - R.leftmost [ show, startHiding, endHiding ] - - modalClass <- - R.holdDyn "" (fmap getModalClass action) - - (elem, dyn) <- - R.buildElement "div" (getAttributes <$> modalClass) $ - ReflexUtil.visibleIfEvent - (isVisible <$> action) - (R.blank >> return (R.never, R.never, R.never)) - (do - (curtain, _) <- R.elAttr' "div" (M.singleton "class" "g-Modal__Curtain") $ R.blank - let curtainClick = R.domEvent R.Click curtain - (hide, content) <- R.divClass "g-Modal__Content" (_in_content input curtainClick) - return (curtainClick, hide, content)) - - - performShowEffects action elem - - let curtainClick = R.switchDyn $ (\(a, _, _) -> a) <$> dyn - let hide = R.switchDyn $ (\(_, b, _) -> b) <$> dyn - let content = R.switchDyn $ (\(_, _, c) -> c) <$> dyn - - -- Delay the event in order to let time for the modal to disappear - R.delay (0.5 :: NominalDiffTime) content - -getAttributes :: Text -> LM.Map Text Text -getAttributes modalClass = - M.singleton "class" $ - T.intercalate " " [ "g-Modal", modalClass] - -performShowEffects - :: forall t m a. MonadWidget t m - => Event t Action - -> Element.Element - -> m () -performShowEffects showEvent elem = do - body <- ReflexUtil.getBody - - let showEffects = - flip fmap showEvent (\case - Show -> do - Node.appendChild body elem - Element.setClassName body ("g-Body--Modal" :: JSString) - StartHiding -> - return () - EndHiding -> do - Node.removeChild body elem - Element.setClassName body ("" :: JSString) - ) - - R.performEvent_ $ void `fmap` showEffects - -data Action - = Show - | StartHiding - | EndHiding - -getModalClass :: Action -> Text -getModalClass Show = "g-Modal--Show" -getModalClass StartHiding = "g-Modal--Hiding" -getModalClass _ = "" - -isVisible :: Action -> Bool -isVisible Show = True -isVisible StartHiding = True -isVisible EndHiding = False diff --git a/client/src/Component/ModalForm.hs b/client/src/Component/ModalForm.hs deleted file mode 100644 index c56ff88..0000000 --- a/client/src/Component/ModalForm.hs +++ /dev/null @@ -1,71 +0,0 @@ -module Component.ModalForm - ( view - , In(..) - , Out(..) - ) where - -import Data.Aeson (ToJSON) -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Calendar (Day) -import Data.Validation (Validation) -import qualified Data.Validation as V -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import qualified Common.Msg as Msg -import qualified Component.Button as Button -import qualified Component.Form as Form -import qualified Util.Either as EitherUtil -import qualified Util.Validation as ValidationUtil -import qualified Util.WaitFor as WaitFor - -data In m t a e = In - { _in_headerLabel :: Text - , _in_form :: m (Dynamic t (Validation e a)) - , _in_ajax :: Event t a -> m (Event t (Either Text ())) - } - -data Out t = Out - { _out_hide :: Event t () - , _out_cancel :: Event t () - , _out_confirm :: Event t () - , _out_validate :: Event t () - } - -view :: forall t m a e. (MonadWidget t m, ToJSON a) => In m t a e -> m (Out t) -view input = - R.divClass "form" $ do - R.divClass "formHeader" $ - R.text (_in_headerLabel input) - - Form.view $ - R.divClass "formContent" $ do - rec - form <- _in_form input - - (validate, cancel, confirm) <- R.divClass "buttons" $ do - rec - cancel <- Button._out_clic <$> (Button.view $ - (Button.defaultIn (R.text $ Msg.get Msg.Dialog_Undo)) - { Button._in_class = R.constDyn "undo" }) - - confirm <- Button._out_clic <$> (Button.view $ - (Button.defaultIn (R.text $ Msg.get Msg.Dialog_Confirm)) - { Button._in_class = R.constDyn "confirm" - , Button._in_waiting = waiting - , Button._in_submit = True - }) - - (validate, waiting) <- WaitFor.waitFor - (_in_ajax input) - (ValidationUtil.fireValidation form confirm) - - return (R.fmapMaybe EitherUtil.eitherToMaybe validate, cancel, confirm) - - return Out - { _out_hide = R.leftmost [ cancel, () <$ validate ] - , _out_cancel = cancel - , _out_confirm = confirm - , _out_validate = validate - } diff --git a/client/src/Component/Pages.hs b/client/src/Component/Pages.hs deleted file mode 100644 index d54cd3d..0000000 --- a/client/src/Component/Pages.hs +++ /dev/null @@ -1,86 +0,0 @@ -module Component.Pages - ( view - , In(..) - , Out(..) - ) where - -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import qualified Component.Button as Button - -import qualified Util.Reflex as ReflexUtil -import qualified View.Icon as Icon - -data In t = In - { _in_total :: Dynamic t Int - , _in_perPage :: Int - , _in_page :: Int - } - -data Out t = Out - { _out_newPage :: Event t Int - } - -view :: forall t m. MonadWidget t m => In t -> m (Out t) -view input = do - newPage <- ReflexUtil.divVisibleIf ((> 0) <$> (_in_total input)) $ pageButtons input - - return $ Out - { _out_newPage = newPage - } - -pageButtons - :: forall t m. MonadWidget t m - => In t - -> m (Event t Int) -pageButtons input = do - R.divClass "pages" $ do - rec - let newPage = R.leftmost - [ firstPageClic - , previousPageClic - , pageClic - , nextPageClic - , lastPageClic - ] - - currentPage <- R.holdDyn (_in_page input) newPage - - firstPageClic <- pageButton noCurrentPage (R.constDyn 1) Icon.doubleLeftBar - - previousPageClic <- pageButton noCurrentPage (fmap (\x -> max (x - 1) 1) currentPage) Icon.doubleLeft - - pageClic <- pageEvent <$> (R.simpleList (range <$> currentPage <*> maxPage) $ \p -> - pageButton (Just <$> currentPage) p (R.dynText $ fmap (T.pack . show) p)) - - nextPageClic <- pageButton noCurrentPage ((\c m -> min (c + 1) m) <$> currentPage <*> maxPage) Icon.doubleRight - - lastPageClic <- pageButton noCurrentPage maxPage Icon.doubleRightBar - - return newPage - - where maxPage = R.ffor (_in_total input) (\t -> ceiling $ toRational t / toRational (_in_perPage input)) - pageEvent = R.switch . R.current . fmap R.leftmost - noCurrentPage = R.constDyn Nothing - -range :: Int -> Int -> [Int] -range currentPage maxPage = [start..end] - where sidePages = 2 - start = max 1 (min (currentPage - sidePages) (maxPage - sidePages * 2)) - end = min maxPage (start + sidePages * 2) - -pageButton :: forall t m. MonadWidget t m => Dynamic t (Maybe Int) -> Dynamic t Int -> m () -> m (Event t Int) -pageButton currentPage page content = do - clic <- Button._out_clic <$> (Button.view $ Button.In - { Button._in_class = do - cp <- currentPage - p <- page - if cp == Just p then "page current" else "page" - , Button._in_content = content - , Button._in_waiting = R.never - , Button._in_tabIndex = Nothing - , Button._in_submit = False - }) - return . fmap fst $ R.attach (R.current page) clic diff --git a/client/src/Component/Select.hs b/client/src/Component/Select.hs deleted file mode 100644 index 70f5f58..0000000 --- a/client/src/Component/Select.hs +++ /dev/null @@ -1,80 +0,0 @@ -module Component.Select - ( view - , In(..) - , Out(..) - ) where - -import Data.Map (Map) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Validation (Validation) -import Reflex.Dom (Dynamic, Event, MonadWidget, Reflex) -import qualified Reflex.Dom as R - -import qualified Util.Validation as ValidationUtil - -data (Reflex t) => In t a b c = In - { _in_label :: Text - , _in_initialValue :: a - , _in_value :: Event t a - , _in_values :: Dynamic t (Map a Text) - , _in_reset :: Event t b - , _in_isValid :: a -> Validation Text a - , _in_validate :: Event t c - } - -data Out t a = Out - { _out_raw :: Dynamic t a - , _out_value :: Dynamic t (Validation Text a) - } - -view :: forall t m a b c. (Ord a, MonadWidget t m) => In t a b c -> m (Out t a) -view input = do - rec - let containerAttr = R.ffor showedError (\e -> - M.singleton "class" $ T.intercalate " " - [ "input selectInput" - , if Maybe.isJust e then "error" else "" - ]) - - validatedValue = - fmap (_in_isValid input) value - - maybeError = - fmap ValidationUtil.maybeError validatedValue - - showedError <- R.holdDyn Nothing $ R.leftmost - [ Nothing <$ _in_reset input - , R.updated maybeError - , R.attachWith const (R.current maybeError) (_in_validate input) - ] - - value <- R.elDynAttr "div" containerAttr $ do - let initialValue = _in_initialValue input - - let setValue = R.leftmost - [ initialValue <$ (_in_reset input) - , _in_value input - ] - - value <- R.el "label" $ do - R.divClass "label" $ - R.text (_in_label input) - - R._dropdown_value <$> - R.dropdown - initialValue - (_in_values input) - (R.def { R._dropdownConfig_setValue = setValue }) - - R.divClass "errorMessage" . R.dynText $ - R.ffor showedError (Maybe.fromMaybe "") - - return value - - return Out - { _out_raw = value - , _out_value = validatedValue - } diff --git a/client/src/Component/Table.hs b/client/src/Component/Table.hs deleted file mode 100644 index 1482f91..0000000 --- a/client/src/Component/Table.hs +++ /dev/null @@ -1,105 +0,0 @@ -module Component.Table - ( view - , In(..) - , Out(..) - ) where - -import qualified Data.Map as M -import Data.Text (Text) -import Reflex.Dom (Event, MonadWidget) -import qualified Reflex.Dom as R - -import qualified Component.Button as Button -import qualified Component.Modal as Modal -import qualified Util.Reflex as ReflexUtil -import qualified View.Icon as Icon - -data In m t h r = In - { _in_headerLabel :: h -> Text - , _in_rows :: [r] - , _in_cell :: h -> r -> m () - , _in_cloneModal :: r -> Modal.Content t m - , _in_editModal :: r -> Modal.Content t m - , _in_deleteModal :: r -> Modal.Content t m - , _in_canEdit :: r -> Bool - , _in_canDelete :: r -> Bool - } - -data Out t = Out - { _out_add :: Event t () - , _out_edit :: Event t () - , _out_delete :: Event t () - } - -view :: forall t m h r. (MonadWidget t m, Bounded h, Enum h) => In m t h r -> m (Out t) -view input = - R.divClass "table" $ do - rec - result <- R.divClass "lines" $ do - - R.divClass "header" $ do - flip mapM_ [minBound..] $ \header -> - R.divClass "cell" . R.text $ - _in_headerLabel input header - - R.divClass "cell" $ R.blank - R.divClass "cell" $ R.blank - R.divClass "cell" $ R.blank - - flip mapM (_in_rows input) $ \row -> - R.divClass "row" $ do - flip mapM_ [minBound..] $ \header -> - R.divClass "cell" $ - _in_cell input header row - - cloneButton <- - R.divClass "cell button" $ - Button._out_clic <$> (Button.view $ - Button.defaultIn Icon.clone) - - clone <- - Modal.view $ Modal.In - { Modal._in_show = cloneButton - , Modal._in_content = _in_cloneModal input row - } - - let visibleIf cond = - R.elAttr - "div" - (if cond then M.empty else M.singleton "style" "display:none") - - editButton <- - R.divClass "cell button" $ - visibleIf (_in_canEdit input row) $ - Button._out_clic <$> (Button.view $ - Button.defaultIn Icon.edit) - - edit <- - Modal.view $ Modal.In - { Modal._in_show = editButton - , Modal._in_content = _in_editModal input row - } - - deleteButton <- - R.divClass "cell button" $ - visibleIf (_in_canDelete input row) $ - Button._out_clic <$> (Button.view $ - Button.defaultIn Icon.delete) - - delete <- - Modal.view $ Modal.In - { Modal._in_show = deleteButton - , Modal._in_content = _in_deleteModal input row - } - - return (clone, edit, delete) - - let add = R.leftmost . map (\(a, _, _) -> a) $ result - edit = R.leftmost . map (\(_, a, _) -> a) $ result - delete = R.leftmost . map (\(_, _, a) -> a) $ result - - return $ Out - { _out_add = add - , _out_edit = edit - , _out_delete = delete - } diff --git a/client/src/Component/Tag.hs b/client/src/Component/Tag.hs deleted file mode 100644 index f75b8d3..0000000 --- a/client/src/Component/Tag.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Component.Tag - ( In(..) - , view - ) where - -import qualified Data.Map as M -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (MonadWidget) -import qualified Reflex.Dom as R - -data In = In - { _in_text :: Text - , _in_color :: Text - } - -view :: forall t m a. MonadWidget t m => In -> m () -view input = - R.elAttr "span" attrs $ - R.text $ _in_text input - - where - attrs = - M.fromList - [ ("class", "tag") - , ("style", T.concat [ "background-color: ", _in_color input ]) - ] diff --git a/client/src/Loadable.hs b/client/src/Loadable.hs deleted file mode 100644 index 4806b08..0000000 --- a/client/src/Loadable.hs +++ /dev/null @@ -1,109 +0,0 @@ -module Loadable - ( Loadable (..) - , fromEither - , fromEvent - , viewHideValueWhileLoading - , viewShowValueWhileLoading - ) where - -import qualified Data.Map as M -import Reflex.Dom (MonadWidget) -import qualified Reflex.Dom as R - -import Data.Functor (Functor) -import Data.Text (Text) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -data Loadable t - = Loading - | Error Text - | Loaded t - deriving (Eq, Show) - -instance Functor Loadable where - fmap f Loading = Loading - fmap f (Error e) = Error e - fmap f (Loaded x) = Loaded (f x) - -instance Applicative Loadable where - pure x = Loaded x - - Loading <*> _ = Loading - (Error e) <*> _ = Error e - (Loaded f) <*> Loading = Loading - (Loaded f) <*> (Error e) = Error e - (Loaded f) <*> (Loaded x) = Loaded (f x) - -instance Monad Loadable where - Loading >>= f = Loading - (Error e) >>= f = Error e - (Loaded x) >>= f = f x - -fromEither :: forall a b. Either Text b -> Loadable b -fromEither (Left err) = Error err -fromEither (Right value) = Loaded value - -fromEvent :: forall t m a. MonadWidget t m => Event t (Either Text a) -> m (Dynamic t (Loadable a)) -fromEvent = - R.foldDyn - (\res _ -> case res of - Left err -> Error err - Right t -> Loaded t - ) - Loading - -viewHideValueWhileLoading :: forall t m a b. MonadWidget t m => (a -> m b) -> Loadable a -> m (Maybe b) -viewHideValueWhileLoading f loadable = - case loadable of - Loading -> - (R.divClass "pageSpinner" $ R.divClass "spinner" $ R.blank) >> return Nothing - - Error err -> - R.text err >> return Nothing - - Loaded x -> - Just <$> f x - -viewShowValueWhileLoading - :: forall t m a b. (MonadWidget t m, Eq a) - => Dynamic t (Loadable a) - -> (a -> m b) - -> m (Event t (Maybe b)) -viewShowValueWhileLoading loadable f = do - - value <- - (R.foldDyn - (\l v1 -> - case l of - Loaded v2 -> Just v2 - _ -> v1) - Nothing - (R.updated loadable)) >>= R.holdUniqDyn - - withLoader (fmap ((==) Loading) loadable) $ - R.dyn . R.ffor value $ \case - Nothing -> return Nothing - Just x -> Just <$> f x - -withLoader - :: forall t m a. MonadWidget t m - => Dynamic t Bool - -> m a - -> m a -withLoader isLoading block = - R.divClass "g-Loadable" $ do - res <- R.elDynAttr "div" (blockAttrs <$> isLoading) $ - block - R.elDynAttr "div" (spinnerAttrs <$> isLoading) $ - R.divClass "spinner" R.blank - return res - - where - spinnerAttrs l = M.singleton "class" $ - "g-Loadable__Spinner" - <> (if l then " g-Loadable__Spinner--Loading" else "") - - blockAttrs l = M.singleton "class" $ - "g-Loadable__Content" - <> (if l then " g-Loadable__Content--Loading" else "") diff --git a/client/src/Main.hs b/client/src/Main.hs deleted file mode 100644 index c71b0f0..0000000 --- a/client/src/Main.hs +++ /dev/null @@ -1,39 +0,0 @@ -module Main - ( main - ) where - -import qualified Data.Aeson as Aeson -import qualified Data.ByteString.Lazy as LB -import qualified Data.JSString.Text as Dom -import qualified Data.Text.Encoding as T -import qualified JSDOM as Dom -import qualified JSDOM.Generated.HTMLElement as Dom -import qualified JSDOM.Generated.NonElementParentNode as Dom -import JSDOM.Types (HTMLElement (..), JSM, - JSString) -import qualified JSDOM.Types as Dom -import Prelude hiding (error, init) - -import Common.Model (Init) -import qualified Common.Msg as Msg - -import qualified View.App as App - -main :: JSM () -main = do - initResult <- readInit - App.widget initResult - -readInit :: JSM (Maybe Init) -readInit = do - document <- Dom.currentDocumentUnchecked - initNode <- Dom.getElementById document ("init" :: JSString) - - case initNode of - Just node -> do - text <- Dom.textFromJSString <$> Dom.getInnerText (Dom.uncheckedCastTo HTMLElement node) - return $ case Aeson.decode (LB.fromStrict . T.encodeUtf8 $ text) of - Just init -> init - Nothing -> Nothing - _ -> - return Nothing diff --git a/client/src/Model/Route.hs b/client/src/Model/Route.hs deleted file mode 100644 index f92e9be..0000000 --- a/client/src/Model/Route.hs +++ /dev/null @@ -1,11 +0,0 @@ -module Model.Route - ( Route(..) - ) where - -data Route - = RootRoute - | IncomeRoute - | CategoryRoute - | StatisticsRoute - | NotFoundRoute - deriving (Eq, Show) diff --git a/client/src/Util/Ajax.hs b/client/src/Util/Ajax.hs deleted file mode 100644 index dcfd402..0000000 --- a/client/src/Util/Ajax.hs +++ /dev/null @@ -1,139 +0,0 @@ -module Util.Ajax - ( getNow - , get - , post - , postAndParseResult - , put - , putAndParseResult - , delete - ) where - -import Control.Arrow (left) -import Data.Aeson (FromJSON, ToJSON) -import qualified Data.Aeson as Aeson -import Data.ByteString (ByteString) -import qualified Data.ByteString.Lazy as LBS -import Data.Default (def) -import qualified Data.Map.Lazy as LM -import Data.Text (Text) -import qualified Data.Text as T -import qualified Data.Text.Encoding as T -import Data.Time.Clock (NominalDiffTime) -import Reflex.Dom (Dynamic, Event, IsXhrPayload, - MonadWidget, XhrRequest, - XhrRequestConfig (..), XhrResponse, - XhrResponseHeaders (..)) -import qualified Reflex.Dom as R - -import Loadable (Loadable) -import qualified Loadable - -getNow :: forall t m a. (MonadWidget t m, FromJSON a) => Text -> m (Dynamic t (Loadable a)) -getNow url = do - postBuild <- R.getPostBuild - get (url <$ postBuild) - >>= R.debounce (0 :: NominalDiffTime) -- Fired 2 times otherwise - >>= Loadable.fromEvent - -get - :: forall t m a. (MonadWidget t m, FromJSON a) - => Event t Text - -> m (Event t (Either Text a)) -get url = - fmap getJsonResult <$> - R.performRequestAsync (R.ffor url $ \u -> jsonRequest "GET" u (Aeson.String "")) - -post - :: forall t m a. (MonadWidget t m, ToJSON a) - => Text - -> Event t a - -> m (Event t (Either Text ())) -post url input = - fmap checkResult <$> - R.performRequestAsync (jsonRequest "POST" url <$> input) - -postAndParseResult - :: forall t m a b. (MonadWidget t m, ToJSON a, FromJSON b) - => Text - -> Event t a - -> m (Event t (Either Text b)) -postAndParseResult url input = - fmap getJsonResult <$> - R.performRequestAsync (jsonRequest "POST" url <$> input) - -put - :: forall t m a. (MonadWidget t m, ToJSON a) - => Text - -> Event t a - -> m (Event t (Either Text ())) -put url input = - fmap checkResult <$> - R.performRequestAsync (jsonRequest "PUT" url <$> input) - -putAndParseResult - :: forall t m a b. (MonadWidget t m, ToJSON a, FromJSON b) - => Text - -> Event t a - -> m (Event t (Either Text b)) -putAndParseResult url input = - fmap getJsonResult <$> - R.performRequestAsync (jsonRequest "PUT" url <$> input) - -delete - :: forall t m a. (MonadWidget t m) - => Dynamic t Text - -> Event t () - -> m (Event t (Either Text Text)) -delete url fire = do - fmap getResult <$> - (R.performRequestAsync $ - R.attachWith (\u _ -> request "DELETE" u ()) (R.current url) fire) - -checkResult :: XhrResponse -> Either Text () -checkResult response = - () <$ getResult response - -getJsonResult :: forall a. (FromJSON a) => XhrResponse -> Either Text a -getJsonResult response = - case getResult response of - Left l -> Left l - Right r -> left T.pack . Aeson.eitherDecodeStrict $ (T.encodeUtf8 r) - -getResult :: XhrResponse -> Either Text Text -getResult response = - case R._xhrResponse_responseText response of - Just responseText -> - if R._xhrResponse_status response == 200 - then Right responseText - else Left responseText - _ -> Left "NoKey" - -request :: forall a. (IsXhrPayload a) => Text -> Text -> a -> XhrRequest a -request method url payload = - let - config = XhrRequestConfig - { _xhrRequestConfig_headers = def - , _xhrRequestConfig_user = def - , _xhrRequestConfig_password = def - , _xhrRequestConfig_responseType = def - , _xhrRequestConfig_responseHeaders = def - , _xhrRequestConfig_withCredentials = False - , _xhrRequestConfig_sendData = payload - } - in - R.xhrRequest method url config - -jsonRequest :: forall a. (ToJSON a) => Text -> Text -> a -> XhrRequest ByteString -jsonRequest method url payload = - let - config = XhrRequestConfig - { _xhrRequestConfig_headers = def - , _xhrRequestConfig_user = def - , _xhrRequestConfig_password = def - , _xhrRequestConfig_responseType = def - , _xhrRequestConfig_responseHeaders = def - , _xhrRequestConfig_withCredentials = False - , _xhrRequestConfig_sendData = LBS.toStrict $ Aeson.encode payload - } - in - R.xhrRequest method url config diff --git a/client/src/Util/Css.hs b/client/src/Util/Css.hs deleted file mode 100644 index 804b10f..0000000 --- a/client/src/Util/Css.hs +++ /dev/null @@ -1,9 +0,0 @@ -module Util.Css - ( classes - ) where - -import Data.Text (Text) -import qualified Data.Text as T - -classes :: [(Text, Bool)] -> Text -classes = T.unwords . map fst . filter snd diff --git a/client/src/Util/Either.hs b/client/src/Util/Either.hs deleted file mode 100644 index e76bc8a..0000000 --- a/client/src/Util/Either.hs +++ /dev/null @@ -1,7 +0,0 @@ -module Util.Either - ( eitherToMaybe - ) where - -eitherToMaybe :: forall a b. Either a b -> Maybe b -eitherToMaybe (Right b) = Just b -eitherToMaybe _ = Nothing diff --git a/client/src/Util/Reflex.hs b/client/src/Util/Reflex.hs deleted file mode 100644 index aa5cebb..0000000 --- a/client/src/Util/Reflex.hs +++ /dev/null @@ -1,59 +0,0 @@ -module Util.Reflex - ( visibleIfDyn - , visibleIfEvent - , divVisibleIf - , divClassVisibleIf - , flatten - , flattenTuple - , getBody - ) where - -import qualified Data.Map as M -import Data.Text (Text) -import qualified GHCJS.DOM as Dom -import qualified GHCJS.DOM.Document as Document -import qualified GHCJS.DOM.HTMLCollection as HTMLCollection -import GHCJS.DOM.Types (Element) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -visibleIfDyn :: forall t m a. MonadWidget t m => Dynamic t Bool -> m a -> m a -> m (Event t a) -visibleIfDyn cond empty content = - R.dyn $ R.ffor cond $ \case - True -> content - False -> empty - -visibleIfEvent :: forall t m a. MonadWidget t m => Event t Bool -> m a -> m a -> m (Dynamic t a) -visibleIfEvent cond empty content = - R.widgetHold empty $ - R.ffor cond $ \case - True -> content - False -> empty - -divVisibleIf :: forall t m a. MonadWidget t m => Dynamic t Bool -> m a -> m a -divVisibleIf cond content = divClassVisibleIf cond "" content - -divClassVisibleIf :: forall t m a. MonadWidget t m => Dynamic t Bool -> Text -> m a -> m a -divClassVisibleIf cond className content = - R.elDynAttr - "div" - (fmap (\c -> (M.singleton "class" className) `M.union` if c then M.empty else M.singleton "style" "display:none") cond) - content - -flatten :: forall t m a. MonadWidget t m => Event t (Event t a) -> m (Event t a) -flatten e = do - dyn <- R.holdDyn R.never e - return $ R.switchDyn dyn - -flattenTuple - :: forall t m a b. MonadWidget t m - => Event t (Event t a, Event t b) - -> m (Event t a, Event t b) -flattenTuple e = (,) <$> (flatten $ fmap fst e) <*> (flatten $ fmap snd e) - -getBody :: forall t m. MonadWidget t m => m Element -getBody = do - document <- Dom.currentDocumentUnchecked - nodelist <- Document.getElementsByTagName document ("body" :: String) - Just body <- nodelist `HTMLCollection.item` 0 - return body diff --git a/client/src/Util/Router.hs b/client/src/Util/Router.hs deleted file mode 100644 index e9d0a1a..0000000 --- a/client/src/Util/Router.hs +++ /dev/null @@ -1,266 +0,0 @@ -{-# LANGUAGE CPP #-} -{-# LANGUAGE ConstraintKinds #-} -{-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE ForeignFunctionInterface #-} -{-# LANGUAGE JavaScriptFFI #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE RankNTypes #-} -{-# LANGUAGE RecursiveDo #-} -{-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TemplateHaskell #-} -{-# LANGUAGE TypeFamilies #-} - -module Util.Router ( - -- == High-level routers - route - , route' - , partialPathRoute - - -- = Low-level URL bar access - , getLoc - , getURI - , getUrlText - , uriOrigin - , URI - - -- = History movement - , goForward - , goBack - ) where - ------------------------------------------------------------------------------- -import Control.Lens ((&), (.~), (^.)) -import Control.Monad.Fix (MonadFix) -import qualified Data.ByteString.Char8 as BS -import Data.Monoid ((<>)) -import Data.Text (Text) -import qualified Data.Text as T -import qualified Data.Text.Encoding as T -import GHCJS.DOM (currentDocumentUnchecked, - currentWindowUnchecked) -import GHCJS.DOM.Document (createEvent) -import GHCJS.DOM.Event (initEvent) -import GHCJS.DOM.EventM (on) -import GHCJS.DOM.EventTarget (dispatchEvent_) -import GHCJS.DOM.History (History, back, forward, - pushState) -import GHCJS.DOM.Location (getHref) -import GHCJS.DOM.PopStateEvent -import GHCJS.DOM.Types (Location (..), - PopStateEvent (..)) -import GHCJS.DOM.Types (MonadJSM, uncheckedCastTo) -import qualified GHCJS.DOM.Types as DOM -import GHCJS.DOM.Window (getHistory, getLocation) -import GHCJS.DOM.WindowEventHandlers (popState) -import GHCJS.Foreign (isFunction) -import GHCJS.Marshal.Pure (pFromJSVal) -import Language.Javascript.JSaddle (JSM, Object (..), ghcjsPure, - liftJSM) -import qualified Language.Javascript.JSaddle as JS -import Reflex.Dom.Core hiding (EventName, Window) -import qualified URI.ByteString as U ------------------------------------------------------------------------------- - - -------------------------------------------------------------------------------- --- | Manipulate and track the URL 'GHCJS.DOM.Types.Location' for dynamic --- routing of a widget --- These sources of URL-bar change will be reflected in the output URI --- - Input events to 'route' --- - Browser Forward/Back button clicks --- - forward/back javascript calls (or 'goForward'/'goBack') Haskell calls --- - Any URL changes followed by a popState event --- But external calls to pushState that don't manually fire a popState --- won't be detected -route - :: forall t m. - ( MonadHold t m - , PostBuild t m - , TriggerEvent t m - , PerformEvent t m - , HasJSContext m - , HasJSContext (Performable m) - , MonadJSM m - , MonadJSM (Performable m)) - => Event t T.Text - -> m (Dynamic t (U.URIRef U.Absolute)) -route pushTo = do - loc0 <- getURI - - _ <- performEvent $ ffor pushTo $ \t -> do - let newState = Just t - withHistory $ \h -> pushState h (0 :: Double) ("" :: T.Text) (newState :: Maybe T.Text) - liftJSM dispatchEvent' - - locUpdates <- getPopState - holdDyn loc0 locUpdates - -route' - :: forall t m a b. - ( MonadHold t m - , PostBuild t m - , TriggerEvent t m - , PerformEvent t m - , HasJSContext m - , HasJSContext (Performable m) - , MonadJSM m - , MonadJSM (Performable m) - , MonadFix m) - => (URI -> a -> URI) - -> (URI -> b) - -> Event t a - -> m (Dynamic t b) -route' encode decode routeUpdate = do - rec rUri <- route (T.decodeUtf8 . U.serializeURIRef' <$> urlUpdates) - let urlUpdates = attachWith encode (current rUri) routeUpdate - return $ decode <$> rUri - - -------------------------------------------------------------------------------- --- | Route a single page app according to the part of the path after --- pathBase -partialPathRoute - :: forall t m. - ( MonadHold t m - , PostBuild t m - , DomBuilder t m - , TriggerEvent t m - , PerformEvent t m - , HasJSContext m - , HasJSContext (Performable m) - , MonadJSM m - , MonadJSM (Performable m) - , MonadFix m) - => T.Text -- ^ The path segments not related to SPA routing - -- (leading '/' will be added automaticaly) - -> Event t T.Text -- ^ Updates to the path segments used for routing - -- These values will be appended to the base path - -> m (Dynamic t [T.Text]) -- ^ Path segments used for routing -partialPathRoute pathBase pathUpdates = do - route' (flip updateUrl) parseParts pathUpdates - where - - rootPathBase :: T.Text - rootPathBase = - if T.null pathBase then - "" - else - "/" <> cleanT pathBase - - toPath :: T.Text -> BS.ByteString - toPath dynpath = T.encodeUtf8 $ rootPathBase <> "/" <> cleanT dynpath - - updateUrl :: T.Text -> URI -> URI - updateUrl updateParts u = u & U.pathL .~ toPath updateParts - - parseParts :: URI -> [T.Text] - parseParts u = - maybe (error $ pfxErr u pathBase) - (T.splitOn "/" . T.decodeUtf8 . cleanB) . - BS.stripPrefix (T.encodeUtf8 $ cleanT pathBase) $ - cleanB (u ^. U.pathL) - - cleanT = T.dropWhile (=='/') - cleanB = BS.dropWhile (== '/') - - -------------------------------------------------------------------------------- -uriOrigin :: U.URIRef U.Absolute -> T.Text -uriOrigin r = T.decodeUtf8 $ U.serializeURIRef' r' - where - r' = r { U.uriPath = mempty - , U.uriQuery = mempty - , U.uriFragment = mempty - } - - -------------------------------------------------------------------------------- -getPopState - :: forall t m. - ( MonadHold t m - , TriggerEvent t m - , MonadJSM m) => m (Event t URI) -getPopState = do - window <- currentWindowUnchecked - wrapDomEventMaybe window (`on` popState) $ do - loc <- getLocation window - locStr <- getHref loc - return . hush $ U.parseURI U.laxURIParserOptions (T.encodeUtf8 locStr) - - -------------------------------------------------------------------------------- -goForward :: (HasJSContext m, MonadJSM m) => m () -goForward = withHistory forward - - -------------------------------------------------------------------------------- -goBack :: (HasJSContext m, MonadJSM m) => m () -goBack = withHistory back - - -------------------------------------------------------------------------------- -withHistory :: (HasJSContext m, MonadJSM m) => (History -> m a) -> m a -withHistory act = do - w <- currentWindowUnchecked - h <- getHistory w - act h - - -------------------------------------------------------------------------------- --- | (Unsafely) get the 'GHCJS.DOM.Location.Location' of a window -getLoc :: (HasJSContext m, MonadJSM m) => m Location -getLoc = do - win <- currentWindowUnchecked - loc <- getLocation win - return loc - - -------------------------------------------------------------------------------- --- | (Unsafely) get the URL text of a window -getUrlText :: (HasJSContext m, MonadJSM m) => m T.Text -getUrlText = getLoc >>= getHref - - -------------------------------------------------------------------------------- -type URI = U.URIRef U.Absolute - - -------------------------------------------------------------------------------- -getURI :: (HasJSContext m, MonadJSM m) => m URI -getURI = do - l <- getUrlText - return $ either (error "No parse of window location") id . - U.parseURI U.laxURIParserOptions $ T.encodeUtf8 l - - -dispatchEvent' :: JSM () -dispatchEvent' = do - window <- currentWindowUnchecked - obj@(Object o) <- JS.create - JS.objSetPropertyByName obj ("cancelable" :: Text) True - JS.objSetPropertyByName obj ("bubbles" :: Text) True - JS.objSetPropertyByName obj ("view" :: Text) window - event <- JS.jsg ("PopStateEvent" :: Text) >>= ghcjsPure . isFunction >>= \case - True -> newPopStateEvent ("popstate" :: Text) $ Just $ pFromJSVal o - False -> do - doc <- currentDocumentUnchecked - event <- createEvent doc ("PopStateEvent" :: Text) - initEvent event ("popstate" :: Text) True True - JS.objSetPropertyByName obj ("view" :: Text) window - return $ uncheckedCastTo PopStateEvent event - - dispatchEvent_ window event - - -------------------------------------------------------------------------------- -hush :: Either e a -> Maybe a -hush (Right a) = Just a -hush _ = Nothing - - -------------------------------------------------------------------------------- -pfxErr :: URI -> T.Text -> String -pfxErr pn pathBase = - T.unpack $ "Encountered path (" <> T.decodeUtf8 (U.serializeURIRef' pn) - <> ") without expected prefix (" <> pathBase <> ")" diff --git a/client/src/Util/Validation.hs b/client/src/Util/Validation.hs deleted file mode 100644 index 50f2468..0000000 --- a/client/src/Util/Validation.hs +++ /dev/null @@ -1,36 +0,0 @@ -module Util.Validation - ( nelError - , toMaybe - , maybeError - , fireValidation - ) where - -import Control.Monad (join) -import Data.List.NonEmpty (NonEmpty) -import qualified Data.List.NonEmpty as NEL -import Data.Text (Text) -import Data.Validation (Validation (Failure, Success)) -import qualified Data.Validation as Validation -import Reflex.Dom (Dynamic, Event, Reflex) -import qualified Reflex.Dom as R - -nelError :: Validation a b -> Validation (NonEmpty a) b -nelError = Validation.validation (Failure . NEL.fromList . (:[])) Success - -toMaybe :: Validation a b -> Maybe b -toMaybe (Success s) = Just s -toMaybe (Failure _) = Nothing - -maybeError :: Validation a b -> Maybe a -maybeError (Success _) = Nothing -maybeError (Failure e) = Just e - -fireValidation - :: forall t a b c. Reflex t - => Dynamic t (Validation a b) - -> Event t c - -> Event t b -fireValidation value validate = - R.fmapMaybe - (Validation.validation (const Nothing) Just) - (R.tag (R.current value) validate) diff --git a/client/src/Util/WaitFor.hs b/client/src/Util/WaitFor.hs deleted file mode 100644 index fe7b733..0000000 --- a/client/src/Util/WaitFor.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Util.WaitFor - ( waitFor - ) where - -import Data.Time (NominalDiffTime) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -waitFor - :: forall t m a b. MonadWidget t m - => (Event t a -> m (Event t b)) - -> Event t a - -> m (Event t b, Event t Bool) -waitFor op input = do - result <- op input >>= R.debounce (0.5 :: NominalDiffTime) - let waiting = R.leftmost [ True <$ input , False <$ result ] - return (result, waiting) diff --git a/client/src/View/App.hs b/client/src/View/App.hs deleted file mode 100644 index 71f0234..0000000 --- a/client/src/View/App.hs +++ /dev/null @@ -1,108 +0,0 @@ -module View.App - ( widget - ) where - -import qualified Data.Text as T -import Prelude hiding (error, init) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Currency, Init (..), UserId) -import qualified Common.Msg as Msg - -import Model.Route (Route (..)) -import qualified Util.Reflex as ReflexUtil -import qualified Util.Router as Router -import qualified View.Category.Category as Category -import qualified View.Header as Header -import qualified View.Income.Income as Income -import qualified View.NotFound as NotFound -import qualified View.Payment.Payment as Payment -import qualified View.SignIn as SignIn -import qualified View.Statistics.Statistics as Statistics - -widget :: Maybe Init -> IO () -widget init = - R.mainWidget $ R.divClass "app" $ do - - route <- getRoute - - rec - header <- Header.view $ Header.In - { Header._in_init = initState - , Header._in_route = route - } - - initState <- - R.foldDyn - const - init - (R.leftmost $ - [ initEvent - , Nothing <$ (Header._out_signOut header) - ]) - - initEvent <- - (R.dyn . R.ffor initState $ \case - Nothing -> do - signIn <- SignIn.view - return (Just <$> SignIn._out_success signIn) - - Just i -> do - signedWidget i route - return R.never) >>= ReflexUtil.flatten - - return () - -signedWidget :: forall t m. MonadWidget t m => Init -> Dynamic t Route -> m () -signedWidget init route = do - R.dyn . R.ffor route $ \case - RootRoute -> - Payment.view $ Payment.In - { Payment._in_currentUser = _init_currentUser init - , Payment._in_currency = _init_currency init - , Payment._in_users = _init_users init - } - - IncomeRoute -> - Income.view $ Income.In - { Income._in_currentUser = _init_currentUser init - , Income._in_currency = _init_currency init - , Income._in_users = _init_users init - } - - CategoryRoute -> - Category.view $ Category.In - { Category._in_currentUser = _init_currentUser init - , Category._in_currency = _init_currency init - , Category._in_users = _init_users init - } - - StatisticsRoute -> - Statistics.view $ Statistics.In - { Statistics._in_currency = _init_currency init - } - - NotFoundRoute -> - NotFound.view - - return () - -getRoute :: forall t m. MonadWidget t m => m (Dynamic t Route) -getRoute = do - r <- Router.partialPathRoute "" . R.switchPromptlyDyn =<< R.holdDyn R.never R.never - return . R.ffor r $ \case - [""] -> - RootRoute - - ["income"] -> - IncomeRoute - - ["category"] -> - CategoryRoute - - ["statistics"] -> - StatisticsRoute - - _ -> - NotFoundRoute diff --git a/client/src/View/Category/Category.hs b/client/src/View/Category/Category.hs deleted file mode 100644 index 5b41bb6..0000000 --- a/client/src/View/Category/Category.hs +++ /dev/null @@ -1,94 +0,0 @@ -{-# LANGUAGE ExplicitForAll #-} - -module View.Category.Category - ( view - , In(..) - ) where - -import Data.Aeson (FromJSON) -import qualified Data.Maybe as Maybe -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Category, CategoryPage (..), Currency, - User, UserId) -import qualified Common.Msg as Msg - -import qualified Component.Button as Button -import qualified Component.Modal as Modal -import qualified Component.Pages as Pages -import Loadable (Loadable (..)) -import qualified Loadable -import qualified Util.Ajax as AjaxUtil -import qualified Util.Reflex as ReflexUtil -import qualified Util.Reflex as ReflexUtil -import qualified View.Category.Form as Form -import qualified View.Category.Reducer as Reducer -import qualified View.Category.Table as Table - -data In t = In - { _in_users :: [User] - , _in_currentUser :: UserId - , _in_currency :: Currency - } - -view :: forall t m. MonadWidget t m => In t -> m () -view input = do - rec - categoryPage <- Reducer.reducer $ Reducer.In - { Reducer._in_page = page - , Reducer._in_addCategory = R.leftmost [ headerAddCategory, tableAddCategory ] - , Reducer._in_editCategory = editCategory - , Reducer._in_deleteCategory = deleteCategory - } - - let eventFromResult :: forall a. ((Event t (), Table.Out t, Pages.Out t) -> Event t a) -> m (Event t a) - eventFromResult op = ReflexUtil.flatten $ (Maybe.fromMaybe R.never . fmap op) <$> result - - page <- eventFromResult $ Pages._out_newPage . (\(_, _, c) -> c) - headerAddCategory <- eventFromResult $ (\(a, _, _) -> a) - tableAddCategory <- eventFromResult $ Table._out_add . (\(_, b, _) -> b) - editCategory <- eventFromResult $ Table._out_edit . (\(_, b, _) -> b) - deleteCategory <- eventFromResult $ Table._out_delete . (\(_, b, _) -> b) - - result <- Loadable.viewShowValueWhileLoading categoryPage $ - \(CategoryPage page categories usedCategories count) -> do - header <- headerView - - table <- Table.view $ Table.In - { Table._in_currentUser = _in_currentUser input - , Table._in_currency = _in_currency input - , Table._in_categories = categories - , Table._in_usedCategories = usedCategories - , Table._in_users = _in_users input - } - - pages <- Pages.view $ Pages.In - { Pages._in_total = R.constDyn count - , Pages._in_perPage = Reducer.perPage - , Pages._in_page = page - } - - return (header, table, pages) - - return () - -headerView :: forall t m. MonadWidget t m => m (Event t ()) -headerView = - R.divClass "withMargin" $ - R.divClass "titleButton" $ do - R.el "h1" $ - R.text $ - Msg.get Msg.Category_Title - - addCategory <- Button._out_clic <$> - (Button.view . Button.defaultIn . R.text $ - Msg.get Msg.Category_Add) - - addCategory <- Modal.view $ Modal.In - { Modal._in_show = addCategory - , Modal._in_content = Form.view $ Form.In { Form._in_operation = Form.New } - } - - return addCategory diff --git a/client/src/View/Category/Form.hs b/client/src/View/Category/Form.hs deleted file mode 100644 index d91fc2e..0000000 --- a/client/src/View/Category/Form.hs +++ /dev/null @@ -1,117 +0,0 @@ -module View.Category.Form - ( view - , In(..) - , Operation(..) - ) where - -import Control.Monad.IO.Class (liftIO) -import Data.Aeson (Value) -import qualified Data.Aeson as Aeson -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import qualified Data.Time.Calendar as Calendar -import qualified Data.Time.Clock as Time -import Data.Validation (Validation) -import qualified Data.Validation as V -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Category (..), - CreateCategoryForm (..), - EditCategoryForm (..)) -import qualified Common.Msg as Msg -import qualified Common.Util.Time as TimeUtil -import qualified Common.Validation.Category as CategoryValidation -import qualified Component.Input as Input -import qualified Component.Modal as Modal -import qualified Component.ModalForm as ModalForm -import qualified Util.Ajax as Ajax - -data In = In - { _in_operation :: Operation - } - -data Operation - = New - | Clone Category - | Edit Category - -view :: forall t m a. MonadWidget t m => In -> Modal.Content t m -view input cancel = do - - rec - let reset = R.leftmost - [ "" <$ ModalForm._out_cancel modalForm - , "" <$ ModalForm._out_validate modalForm - , "" <$ cancel - ] - - modalForm <- ModalForm.view $ ModalForm.In - { ModalForm._in_headerLabel = headerLabel - , ModalForm._in_ajax = ajax "/api/category" - , ModalForm._in_form = form reset (ModalForm._out_confirm modalForm) - } - - return (ModalForm._out_hide modalForm, ModalForm._out_validate modalForm) - - where - - form - :: Event t String - -> Event t () - -> m (Dynamic t (Validation Text Value)) - form reset confirm = do - name <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Category_Name - , Input._in_initialValue = name - , Input._in_validation = CategoryValidation.name - }) - (name <$ reset) - confirm) - - color <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Category_Color - , Input._in_initialValue = color - , Input._in_inputType = "color" - , Input._in_hasResetButton = False - , Input._in_validation = CategoryValidation.color - }) - (color <$ reset) - confirm) - - return $ do - n <- name - c <- color - return . V.Success $ mkPayload n c - - op = _in_operation input - - name = - case op of - New -> "" - Clone c -> _category_name c - Edit c -> _category_name c - - color = - case op of - New -> "" - Clone c -> _category_color c - Edit c -> _category_color c - - ajax = - case op of - Edit _ -> Ajax.put - _ -> Ajax.post - - headerLabel = - case op of - Edit _ -> Msg.get Msg.Category_Edit - _ -> Msg.get Msg.Category_Add - - mkPayload = - case op of - Edit i -> \a b -> Aeson.toJSON $ EditCategoryForm (_category_id i) a b - _ -> \a b -> Aeson.toJSON $ CreateCategoryForm a b diff --git a/client/src/View/Category/Reducer.hs b/client/src/View/Category/Reducer.hs deleted file mode 100644 index 5ad0ddb..0000000 --- a/client/src/View/Category/Reducer.hs +++ /dev/null @@ -1,59 +0,0 @@ -module View.Category.Reducer - ( perPage - , reducer - , In(..) - ) where - -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (CategoryPage) - -import Loadable (Loadable (..)) -import qualified Loadable as Loadable -import qualified Util.Ajax as AjaxUtil -import qualified Util.Either as EitherUtil - -perPage :: Int -perPage = 7 - -data In t a b c = In - { _in_page :: Event t Int - , _in_addCategory :: Event t a - , _in_editCategory :: Event t b - , _in_deleteCategory :: Event t c - } - -reducer :: forall t m a b c. MonadWidget t m => In t a b c -> m (Dynamic t (Loadable CategoryPage)) -reducer input = do - - postBuild <- R.getPostBuild - - currentPage <- R.holdDyn 1 (_in_page input) - - let loadPage = - R.leftmost - [ 1 <$ postBuild - , _in_page input - , 1 <$ _in_addCategory input - , R.tag (R.current currentPage) (_in_editCategory input) - , R.tag (R.current currentPage) (_in_deleteCategory input) - ] - - getResult <- AjaxUtil.get $ fmap pageUrl loadPage - - R.holdDyn - Loading - (R.leftmost - [ Loading <$ loadPage - , Loadable.fromEither <$> getResult - ]) - - where - pageUrl p = - "api/categories?page=" - <> (T.pack . show $ p) - <> "&perPage=" - <> (T.pack . show $ perPage) diff --git a/client/src/View/Category/Table.hs b/client/src/View/Category/Table.hs deleted file mode 100644 index 90d013d..0000000 --- a/client/src/View/Category/Table.hs +++ /dev/null @@ -1,93 +0,0 @@ -module View.Category.Table - ( view - , In(..) - , Out(..) - ) where - -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Category (..), CategoryId, Currency, - User (..), UserId) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -import qualified Component.ConfirmDialog as ConfirmDialog -import qualified Component.Table as Table -import qualified Component.Tag as Tag -import qualified Util.Ajax as Ajax -import qualified Util.Either as EitherUtil -import qualified View.Category.Form as Form - -data In t = In - { _in_currentUser :: UserId - , _in_currency :: Currency - , _in_categories :: [Category] - , _in_usedCategories :: [CategoryId] - , _in_users :: [User] - } - -data Out t = Out - { _out_add :: Event t () - , _out_edit :: Event t () - , _out_delete :: Event t () - } - -view :: forall t m. MonadWidget t m => In t -> m (Out t) -view input = do - - table <- Table.view $ Table.In - { Table._in_headerLabel = headerLabel - , Table._in_rows = _in_categories input - , Table._in_cell = cell (_in_users input) (_in_currency input) - , Table._in_cloneModal = \category -> - Form.view $ Form.In - { Form._in_operation = Form.Clone category - } - , Table._in_editModal = \category -> - Form.view $ Form.In - { Form._in_operation = Form.Edit category - } - , Table._in_deleteModal = \category -> - ConfirmDialog.view $ ConfirmDialog.In - { ConfirmDialog._in_header = Msg.get Msg.Category_DeleteConfirm - , ConfirmDialog._in_confirm = \e -> do - res <- Ajax.delete - (R.constDyn $ T.concat ["/api/category/", T.pack . show $ _category_id category]) - e - return $ () <$ R.fmapMaybe EitherUtil.eitherToMaybe res - } - , Table._in_canEdit = const True - , Table._in_canDelete = not . flip elem (_in_usedCategories input) . _category_id - } - - return $ Out - { _out_add = Table._out_add table - , _out_edit = Table._out_edit table - , _out_delete = Table._out_delete table - } - -data Header - = NameHeader - | ColorHeader - deriving (Eq, Show, Bounded, Enum) - -headerLabel :: Header -> Text -headerLabel NameHeader = Msg.get Msg.Category_Name -headerLabel ColorHeader = Msg.get Msg.Category_Color - -cell :: forall t m. MonadWidget t m => [User] -> Currency -> Header -> Category -> m () -cell users currency header category = - case header of - NameHeader -> - R.text $ _category_name category - - ColorHeader -> - Tag.view $ Tag.In - { Tag._in_text = _category_name category - , Tag._in_color = _category_color category - } diff --git a/client/src/View/Header.hs b/client/src/View/Header.hs deleted file mode 100644 index ff9f40a..0000000 --- a/client/src/View/Header.hs +++ /dev/null @@ -1,123 +0,0 @@ -module View.Header - ( view - , In(..) - , Out(..) - ) where - -import Data.Map (Map) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time (NominalDiffTime) -import Prelude hiding (error, init) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Init (..), User (..)) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Component.Button as Button -import qualified Component.Link as Link -import Model.Route (Route (..)) -import qualified Util.Css as CssUtil -import qualified Util.Reflex as ReflexUtil -import qualified View.Icon as Icon - -data In t = In - { _in_init :: Dynamic t (Maybe Init) - , _in_route :: Dynamic t Route - } - -data Out t = Out - { _out_signOut :: Event t () - } - -view :: forall t m. MonadWidget t m => (In t) -> m (Out t) -view input = - R.el "header" $ do - - R.divClass "title" $ - R.text $ Msg.get Msg.App_Title - - let showLinks = Maybe.isJust <$> _in_init input - - signOut <- R.el "div" $ do - ReflexUtil.visibleIfDyn showLinks R.blank (links $ _in_route input) - (R.dyn $ nameSignOut <$> _in_init input) >>= ReflexUtil.flatten - - return $ Out - { _out_signOut = signOut - } - -links :: forall t m. MonadWidget t m => Dynamic t Route -> m () -links route = do - Link.view - "/" - (R.ffor route (attrs RootRoute)) - (Msg.get Msg.Payment_Title) - - Link.view - "/income" - (R.ffor route (attrs IncomeRoute)) - (Msg.get Msg.Income_Title) - - Link.view - "/category" - (R.ffor route (attrs CategoryRoute)) - (Msg.get Msg.Category_Title) - - Link.view - "/statistics" - (R.ffor route (attrs StatisticsRoute)) - (Msg.get Msg.Statistics_Title) - - where - attrs linkRoute currentRoute = - M.singleton "class" $ - CssUtil.classes - [ ("item", True) - , ("current", linkRoute == currentRoute) - ] - -nameSignOut :: forall t m. MonadWidget t m => Maybe Init -> m (Event t ()) -nameSignOut init = - case init of - Just init -> do - rec - attr <- R.holdDyn - (M.singleton "class" "nameSignOut") - (fmap (const $ M.fromList [("style", "visibility: hidden"), ("class", "nameSignOut")]) signOut) - - signOut <- R.elDynAttr "nameSignOut" attr $ do - case CM.findUser (_init_currentUser init) (_init_users init) of - Just user -> R.divClass "name" $ R.text (_user_name user) - Nothing -> R.blank - signOutButton - - return signOut - _ -> - return R.never - -signOutButton :: forall t m. MonadWidget t m => m (Event t ()) -signOutButton = do - rec - signOut <- Button.view $ - (Button.defaultIn Icon.signOut) - { Button._in_class = R.constDyn "signOut item" - , Button._in_waiting = waiting - } - let signOutClic = Button._out_clic signOut - waiting = R.leftmost - [ fmap (const True) signOutClic - , fmap (const False) signOutSuccess - ] - signOutSuccess <- askSignOut signOutClic >>= R.debounce (0.5 :: NominalDiffTime) - - return . fmap (const ()) . R.ffilter (== True) $ signOutSuccess - - where askSignOut :: forall t m. MonadWidget t m => Event t () -> m (Event t Bool) - askSignOut signOut = - fmap getResult <$> R.performRequestAsync xhrRequest - where xhrRequest = fmap (const $ R.postJson "/api/signOut" ()) signOut - getResult = (== 200) . R._xhrResponse_status diff --git a/client/src/View/Icon.hs b/client/src/View/Icon.hs deleted file mode 100644 index cc2ef3f..0000000 --- a/client/src/View/Icon.hs +++ /dev/null @@ -1,71 +0,0 @@ -module View.Icon - ( clone - , cross - , delete - , edit - , loading - , doubleLeft - , doubleLeftBar - , doubleRight - , doubleRightBar - , signOut - ) where - -import Data.Map (Map) -import qualified Data.Map as M -import Data.Text (Text) -import Reflex.Dom (MonadWidget) -import qualified Reflex.Dom as R - -clone :: forall t m. MonadWidget t m => m () -clone = - svgAttr "svg" (M.fromList [ ("width", "24"), ("height", "24"), ("viewBox", "0 0 24 24") ]) $ - svgAttr "path" (M.fromList [("d", "M15.143 13.244l.837-2.244 2.698 5.641-5.678 2.502.805-2.23s-8.055-3.538-7.708-10.913c2.715 5.938 9.046 7.244 9.046 7.244zm8.857-7.244v18h-18v-6h-6v-18h18v6h6zm-2 2h-12.112c-.562-.578-1.08-1.243-1.521-2h7.633v-4h-14v14h4v-3.124c.6.961 1.287 1.823 2 2.576v6.548h14v-14z")]) $ R.blank - -cross :: forall t m. MonadWidget t m => m () -cross = - svgAttr "svg" (M.fromList [ ("width", "15"), ("height", "15"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z")]) $ R.blank - -delete :: forall t m. MonadWidget t m => m () -delete = - svgAttr "svg" (M.fromList [ ("width", "18"), ("height", "18"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M704 1376v-704q0-14-9-23t-23-9h-64q-14 0-23 9t-9 23v704q0 14 9 23t23 9h64q14 0 23-9t9-23zm256 0v-704q0-14-9-23t-23-9h-64q-14 0-23 9t-9 23v704q0 14 9 23t23 9h64q14 0 23-9t9-23zm256 0v-704q0-14-9-23t-23-9h-64q-14 0-23 9t-9 23v704q0 14 9 23t23 9h64q14 0 23-9t9-23zm-544-992h448l-48-117q-7-9-17-11h-317q-10 2-17 11zm928 32v64q0 14-9 23t-23 9h-96v948q0 83-47 143.5t-113 60.5h-832q-66 0-113-58.5t-47-141.5v-952h-96q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h309l70-167q15-37 54-63t79-26h320q40 0 79 26t54 63l70 167h309q14 0 23 9t9 23z")]) $ R.blank - -doubleLeft :: forall t m. MonadWidget t m => m () -doubleLeft = - svgAttr "svg" (M.fromList [ ("width", "13"), ("height", "13"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M1683 141q19-19 32-13t13 32v1472q0 26-13 32t-32-13l-710-710q-8-9-13-19v710q0 26-13 32t-32-13l-710-710q-19-19-19-45t19-45l710-710q19-19 32-13t13 32v710q5-11 13-19z")]) $ R.blank - -doubleLeftBar :: forall t m. MonadWidget t m => m () -doubleLeftBar = - svgAttr "svg" (M.fromList [ ("width", "13"), ("height", "13"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M1747 141q19-19 32-13t13 32v1472q0 26-13 32t-32-13l-710-710q-9-9-13-19v710q0 26-13 32t-32-13l-710-710q-9-9-13-19v678q0 26-19 45t-45 19h-128q-26 0-45-19t-19-45v-1408q0-26 19-45t45-19h128q26 0 45 19t19 45v678q4-11 13-19l710-710q19-19 32-13t13 32v710q4-11 13-19z")]) $ R.blank - -doubleRight :: forall t m. MonadWidget t m => m () -doubleRight = - svgAttr "svg" (M.fromList [ ("width", "13"), ("height", "13"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M109 1651q-19 19-32 13t-13-32v-1472q0-26 13-32t32 13l710 710q8 8 13 19v-710q0-26 13-32t32 13l710 710q19 19 19 45t-19 45l-710 710q-19 19-32 13t-13-32v-710q-5 10-13 19z")]) $ R.blank - -doubleRightBar :: forall t m. MonadWidget t m => m () -doubleRightBar = - svgAttr "svg" (M.fromList [ ("width", "13"), ("height", "13"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M45 1651q-19 19-32 13t-13-32v-1472q0-26 13-32t32 13l710 710q8 8 13 19v-710q0-26 13-32t32 13l710 710q8 8 13 19v-678q0-26 19-45t45-19h128q26 0 45 19t19 45v1408q0 26-19 45t-45 19h-128q-26 0-45-19t-19-45v-678q-5 10-13 19l-710 710q-19 19-32 13t-13-32v-710q-5 10-13 19z")]) $ R.blank - -edit :: forall t m. MonadWidget t m => m () -edit = - svgAttr "svg" (M.fromList [ ("width", "18"), ("height", "18"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z")]) $ R.blank - -loading :: forall t m. MonadWidget t m => m () -loading = - svgAttr "svg" (M.fromList [ ("width", "24"), ("height", "24"), ("viewBox", "0 0 24 24"), ("class", "loader"), ("fill", "currentColor") ]) $ - svgAttr "path" (M.fromList [("d", "M13.75 22c0 .966-.783 1.75-1.75 1.75s-1.75-.784-1.75-1.75.783-1.75 1.75-1.75 1.75.784 1.75 1.75zm-1.75-22c-1.104 0-2 .896-2 2s.896 2 2 2 2-.896 2-2-.896-2-2-2zm10 10.75c.689 0 1.249.561 1.249 1.25 0 .69-.56 1.25-1.249 1.25-.69 0-1.249-.559-1.249-1.25 0-.689.559-1.25 1.249-1.25zm-22 1.25c0 1.105.896 2 2 2s2-.895 2-2c0-1.104-.896-2-2-2s-2 .896-2 2zm19-8c.551 0 1 .449 1 1 0 .553-.449 1.002-1 1-.551 0-1-.447-1-.998 0-.553.449-1.002 1-1.002zm0 13.5c.828 0 1.5.672 1.5 1.5s-.672 1.501-1.502 1.5c-.826 0-1.498-.671-1.498-1.499 0-.829.672-1.501 1.5-1.501zm-14-14.5c1.104 0 2 .896 2 2s-.896 2-2.001 2c-1.103 0-1.999-.895-1.999-2s.896-2 2-2zm0 14c1.104 0 2 .896 2 2s-.896 2-2.001 2c-1.103 0-1.999-.895-1.999-2s.896-2 2-2z")]) $ R.blank - -signOut :: forall t m. MonadWidget t m => m () -signOut = - svgAttr "svg" (M.fromList [ ("width", "30"), ("height", "30"), ("viewBox", "0 0 1792 1792") ]) $ - svgAttr "path" (M.fromList [("d", "M1664 896q0 156-61 298t-164 245-245 164-298 61-298-61-245-164-164-245-61-298q0-182 80.5-343t226.5-270q43-32 95.5-25t83.5 50q32 42 24.5 94.5t-49.5 84.5q-98 74-151.5 181t-53.5 228q0 104 40.5 198.5t109.5 163.5 163.5 109.5 198.5 40.5 198.5-40.5 163.5-109.5 109.5-163.5 40.5-198.5q0-121-53.5-228t-151.5-181q-42-32-49.5-84.5t24.5-94.5q31-43 84-50t95 25q146 109 226.5 270t80.5 343zm-640-768v640q0 52-38 90t-90 38-90-38-38-90v-640q0-52 38-90t90-38 90 38 38 90z")]) $ R.blank - -svgAttr :: forall t m a. MonadWidget t m => Text -> Map Text Text -> m a -> m a -svgAttr elementTag attrs child = R.elWith elementTag (R.ElConfig (Just "http://www.w3.org/2000/svg") attrs) child diff --git a/client/src/View/Income/Form.hs b/client/src/View/Income/Form.hs deleted file mode 100644 index 59f6a0d..0000000 --- a/client/src/View/Income/Form.hs +++ /dev/null @@ -1,119 +0,0 @@ -module View.Income.Form - ( view - , In(..) - , Operation(..) - ) where - -import Control.Monad.IO.Class (liftIO) -import Data.Aeson (Value) -import qualified Data.Aeson as Aeson -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import qualified Data.Time.Calendar as Calendar -import qualified Data.Time.Clock as Time -import Data.Validation (Validation) -import qualified Data.Validation as V -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (CreateIncomeForm (..), - EditIncomeForm (..), Income (..)) -import qualified Common.Msg as Msg -import qualified Common.Util.Time as TimeUtil -import qualified Common.Validation.Income as IncomeValidation -import qualified Component.Input as Input -import qualified Component.Modal as Modal -import qualified Component.ModalForm as ModalForm -import qualified Util.Ajax as Ajax - -data In = In - { _in_operation :: Operation - } - -data Operation - = New - | Clone Income - | Edit Income - -view :: forall t m a. MonadWidget t m => In -> Modal.Content t m -view input cancel = do - - rec - let reset = R.leftmost - [ "" <$ ModalForm._out_cancel modalForm - , "" <$ ModalForm._out_validate modalForm - , "" <$ cancel - ] - - modalForm <- ModalForm.view $ ModalForm.In - { ModalForm._in_headerLabel = headerLabel - , ModalForm._in_ajax = ajax "/api/income" - , ModalForm._in_form = form reset (ModalForm._out_confirm modalForm) - } - - return (ModalForm._out_hide modalForm, ModalForm._out_validate modalForm) - - where - - form - :: Event t String - -> Event t () - -> m (Dynamic t (Validation Text Value)) - form reset confirm = do - amount <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Income_Amount - , Input._in_initialValue = amount - , Input._in_validation = IncomeValidation.amount - }) - (amount <$ reset) - confirm) - - currentDay <- liftIO $ Time.getCurrentTime >>= TimeUtil.timeToDay - - let initialDate = T.pack . Calendar.showGregorian $ date currentDay - - date <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Income_Date - , Input._in_initialValue = initialDate - , Input._in_inputType = "date" - , Input._in_hasResetButton = False - , Input._in_validation = IncomeValidation.date - }) - (initialDate <$ reset) - confirm) - - return $ do - a <- amount - d <- date - return . V.Success $ mkPayload a d - - op = _in_operation input - - amount = - case op of - New -> "" - Clone i -> T.pack . show . _income_amount $ i - Edit i -> T.pack . show . _income_amount $ i - - date currentDay = - case op of - Edit i -> _income_date i - _ -> currentDay - - ajax = - case op of - Edit _ -> Ajax.put - _ -> Ajax.post - - headerLabel = - case op of - Edit _ -> Msg.get Msg.Income_Edit - _ -> Msg.get Msg.Income_AddLong - - mkPayload = - case op of - Edit i -> \a b -> Aeson.toJSON $ EditIncomeForm (_income_id i) a b - _ -> \a b -> Aeson.toJSON $ CreateIncomeForm a b diff --git a/client/src/View/Income/Header.hs b/client/src/View/Income/Header.hs deleted file mode 100644 index a26e16a..0000000 --- a/client/src/View/Income/Header.hs +++ /dev/null @@ -1,77 +0,0 @@ -module View.Income.Header - ( view - , In(..) - , Out(..) - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import qualified Data.Text as T -import qualified Data.Time.Clock as Clock -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Currency, Income (..), - IncomeHeader (..), User (..)) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -import qualified Component.Button as Button -import qualified Component.Modal as Modal -import qualified View.Income.Form as Form - -data In t = In - { _in_users :: [User] - , _in_header :: IncomeHeader - , _in_currency :: Currency - } - -data Out t = Out - { _out_add :: Event t () - } - -view :: forall t m. MonadWidget t m => In t -> m (Out t) -view input = - R.divClass "withMargin" $ do - - currentTime <- liftIO Clock.getCurrentTime - - case _incomeHeader_since $ _in_header input of - Nothing -> - R.blank - - Just since -> - R.el "div" $ do - - R.el "h1" $ do - R.text $ Msg.get (Msg.Income_CumulativeSince (Format.longDay since)) - - R.el "ul" $ - flip mapM_ (M.toList . _incomeHeader_byUser $ _in_header input) $ \(userId, amount) -> - R.el "li" $ - R.text $ - T.intercalate " " - [ Maybe.fromMaybe "" . fmap _user_name $ CM.findUser userId (_in_users input) - , "−" - , Format.price (_in_currency input) amount - ] - - R.divClass "titleButton" $ do - R.el "h1" $ - R.text $ - Msg.get Msg.Income_MonthlyNet - - addIncome <- Button._out_clic <$> - (Button.view . Button.defaultIn . R.text $ - Msg.get Msg.Income_AddLong) - - addIncome <- Modal.view $ Modal.In - { Modal._in_show = addIncome - , Modal._in_content = Form.view $ Form.In { Form._in_operation = Form.New } - } - - return $ Out - { _out_add = addIncome - } diff --git a/client/src/View/Income/Income.hs b/client/src/View/Income/Income.hs deleted file mode 100644 index 7be8091..0000000 --- a/client/src/View/Income/Income.hs +++ /dev/null @@ -1,75 +0,0 @@ -{-# LANGUAGE ExplicitForAll #-} - -module View.Income.Income - ( view - , In(..) - ) where - -import Data.Aeson (FromJSON) -import qualified Data.Maybe as Maybe -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Currency, Income (..), IncomePage (..), - User, UserId) - -import qualified Component.Pages as Pages -import Loadable (Loadable (..)) -import qualified Loadable -import qualified Util.Ajax as AjaxUtil -import qualified Util.Reflex as ReflexUtil -import qualified Util.Reflex as ReflexUtil -import qualified View.Income.Header as Header -import qualified View.Income.Reducer as Reducer -import qualified View.Income.Table as Table - -data In t = In - { _in_users :: [User] - , _in_currentUser :: UserId - , _in_currency :: Currency - } - -view :: forall t m. MonadWidget t m => In t -> m () -view input = do - rec - incomePage <- Reducer.reducer $ Reducer.In - { Reducer._in_page = page - , Reducer._in_addIncome = R.leftmost [headerAddIncome, tableAddIncome] - , Reducer._in_editIncome = editIncome - , Reducer._in_deleteIncome = deleteIncome - } - - let eventFromResult :: forall a. ((Header.Out t, Table.Out t, Pages.Out t) -> Event t a) -> m (Event t a) - eventFromResult op = ReflexUtil.flatten . fmap (Maybe.fromMaybe R.never . fmap op) $ result - - page <- eventFromResult $ Pages._out_newPage . (\(_, _, c) -> c) - headerAddIncome <- eventFromResult $ Header._out_add . (\(a, _, _) -> a) - tableAddIncome <- eventFromResult $ Table._out_add . (\(_, b, _) -> b) - editIncome <- eventFromResult $ Table._out_edit . (\(_, b, _) -> b) - deleteIncome <- eventFromResult $ Table._out_delete . (\(_, b, _) -> b) - - result <- Loadable.viewShowValueWhileLoading incomePage $ - \(IncomePage page header incomes count) -> do - header <- Header.view $ Header.In - { Header._in_users = _in_users input - , Header._in_header = header - , Header._in_currency = _in_currency input - } - - table <- Table.view $ Table.In - { Table._in_currentUser = _in_currentUser input - , Table._in_currency = _in_currency input - , Table._in_incomes = incomes - , Table._in_users = _in_users input - } - - pages <- Pages.view $ Pages.In - { Pages._in_total = R.constDyn count - , Pages._in_perPage = Reducer.perPage - , Pages._in_page = page - } - - return (header, table, pages) - - return () diff --git a/client/src/View/Income/Reducer.hs b/client/src/View/Income/Reducer.hs deleted file mode 100644 index ea9f664..0000000 --- a/client/src/View/Income/Reducer.hs +++ /dev/null @@ -1,59 +0,0 @@ -module View.Income.Reducer - ( perPage - , reducer - , In(..) - ) where - -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (IncomePage) - -import Loadable (Loadable (..)) -import qualified Loadable as Loadable -import qualified Util.Ajax as AjaxUtil -import qualified Util.Either as EitherUtil - -perPage :: Int -perPage = 7 - -data In t a b c = In - { _in_page :: Event t Int - , _in_addIncome :: Event t a - , _in_editIncome :: Event t b - , _in_deleteIncome :: Event t c - } - -reducer :: forall t m a b c. MonadWidget t m => In t a b c -> m (Dynamic t (Loadable IncomePage)) -reducer input = do - - postBuild <- R.getPostBuild - - currentPage <- R.holdDyn 1 (_in_page input) - - let loadPage = - R.leftmost - [ 1 <$ postBuild - , _in_page input - , 1 <$ _in_addIncome input - , R.tag (R.current currentPage) (_in_editIncome input) - , R.tag (R.current currentPage) (_in_deleteIncome input) - ] - - getResult <- AjaxUtil.get $ fmap pageUrl loadPage - - R.holdDyn - Loading - (R.leftmost - [ Loading <$ loadPage - , Loadable.fromEither <$> getResult - ]) - - where - pageUrl p = - "api/incomes?page=" - <> (T.pack . show $ p) - <> "&perPage=" - <> (T.pack . show $ perPage) diff --git a/client/src/View/Income/Table.hs b/client/src/View/Income/Table.hs deleted file mode 100644 index 7b7940d..0000000 --- a/client/src/View/Income/Table.hs +++ /dev/null @@ -1,93 +0,0 @@ -module View.Income.Table - ( view - , In(..) - , Out(..) - ) where - -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Currency, Income (..), User (..), - UserId) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -import qualified Component.ConfirmDialog as ConfirmDialog -import qualified Component.Table as Table -import qualified Util.Ajax as Ajax -import qualified Util.Either as EitherUtil -import qualified View.Income.Form as Form - -data In t = In - { _in_currentUser :: UserId - , _in_currency :: Currency - , _in_incomes :: [Income] - , _in_users :: [User] - } - -data Out t = Out - { _out_add :: Event t () - , _out_edit :: Event t () - , _out_delete :: Event t () - } - -view :: forall t m. MonadWidget t m => In t -> m (Out t) -view input = do - - table <- Table.view $ Table.In - { Table._in_headerLabel = headerLabel - , Table._in_rows = _in_incomes input - , Table._in_cell = cell (_in_users input) (_in_currency input) - , Table._in_cloneModal = \income -> - Form.view $ Form.In - { Form._in_operation = Form.Clone income - } - , Table._in_editModal = \income -> - Form.view $ Form.In - { Form._in_operation = Form.Edit income - } - , Table._in_deleteModal = \income -> - ConfirmDialog.view $ ConfirmDialog.In - { ConfirmDialog._in_header = Msg.get Msg.Income_DeleteConfirm - , ConfirmDialog._in_confirm = \e -> do - res <- Ajax.delete - (R.constDyn $ T.concat ["/api/income/", T.pack . show $ _income_id income]) - e - return $ () <$ R.fmapMaybe EitherUtil.eitherToMaybe res - } - , Table._in_canEdit = (== (_in_currentUser input)) . _income_userId - , Table._in_canDelete = (== (_in_currentUser input)) . _income_userId - } - - return $ Out - { _out_add = Table._out_add table - , _out_edit = Table._out_edit table - , _out_delete = Table._out_delete table - } - -data Header - = UserHeader - | AmountHeader - | DateHeader - deriving (Eq, Show, Bounded, Enum) - -headerLabel :: Header -> Text -headerLabel UserHeader = Msg.get Msg.Income_Name -headerLabel DateHeader = Msg.get Msg.Income_Date -headerLabel AmountHeader = Msg.get Msg.Income_Amount - -cell :: forall t m. MonadWidget t m => [User] -> Currency -> Header -> Income -> m () -cell users currency header income = - case header of - UserHeader -> - R.text . Maybe.fromMaybe "" . fmap _user_name $ CM.findUser (_income_userId income) users - - DateHeader -> - R.text . Format.longDay . _income_date $ income - - AmountHeader -> - R.text . Format.price currency . _income_amount $ income diff --git a/client/src/View/NotFound.hs b/client/src/View/NotFound.hs deleted file mode 100644 index 1597849..0000000 --- a/client/src/View/NotFound.hs +++ /dev/null @@ -1,20 +0,0 @@ -module View.NotFound - ( view - ) where - -import qualified Data.Map as M -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import qualified Common.Msg as Msg -import qualified Component.Link as Link - -view :: forall t m. MonadWidget t m => m () -view = - R.divClass "notfound" $ do - R.text (Msg.get Msg.NotFound_Message) - - Link.view - "/" - (R.constDyn $ M.singleton "class" "link") - (Msg.get Msg.NotFound_LinkMessage) diff --git a/client/src/View/Payment/Form.hs b/client/src/View/Payment/Form.hs deleted file mode 100644 index 6c31fad..0000000 --- a/client/src/View/Payment/Form.hs +++ /dev/null @@ -1,199 +0,0 @@ -module View.Payment.Form - ( view - , In(..) - , Operation(..) - ) where - -import Control.Monad (join) -import Control.Monad.IO.Class (liftIO) -import Data.Aeson (Value) -import qualified Data.Aeson as Aeson -import qualified Data.List as L -import Data.List.NonEmpty (NonEmpty) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time (NominalDiffTime) -import Data.Time.Calendar (Day) -import qualified Data.Time.Calendar as Calendar -import qualified Data.Time.Clock as Clock -import Data.Validation (Validation) -import qualified Data.Validation as V -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R -import qualified Text.Read as T - -import Common.Model (Category (..), CategoryId, - CreatePaymentForm (..), - EditPaymentForm (..), - Frequency (..), Payment (..)) -import qualified Common.Msg as Msg -import qualified Common.Util.Time as TimeUtil -import qualified Common.Validation.Payment as PaymentValidation - -import qualified Component.Input as Input -import qualified Component.Modal as Modal -import qualified Component.ModalForm as ModalForm -import qualified Component.Select as Select -import qualified Util.Ajax as Ajax -import qualified Util.Either as EitherUtil -import qualified Util.Validation as ValidationUtil - -data In t = In - { _in_categories :: [Category] - , _in_operation :: Operation t - , _in_frequency :: Frequency - } - -data Operation t - = New - | Clone Payment - | Edit Payment - -view :: forall t m a. MonadWidget t m => In t -> Modal.Content t m -view input cancel = do - rec - let reset = R.leftmost - [ "" <$ ModalForm._out_cancel modalForm - , "" <$ ModalForm._out_validate modalForm - , "" <$ cancel - ] - - modalForm <- ModalForm.view $ ModalForm.In - { ModalForm._in_headerLabel = headerLabel - , ModalForm._in_ajax = ajax "/api/payment" - , ModalForm._in_form = form reset (ModalForm._out_confirm modalForm) - } - - return (ModalForm._out_hide modalForm, ModalForm._out_validate modalForm) - - where - - form - :: Event t String - -> Event t () - -> m (Dynamic t (Validation (NonEmpty Text) Value)) - form reset confirm = do - name <- Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Payment_Name - , Input._in_initialValue = name - , Input._in_validation = PaymentValidation.name - }) - (name <$ reset) - confirm - - cost <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Payment_Cost - , Input._in_initialValue = cost - , Input._in_validation = PaymentValidation.cost - }) - (cost <$ reset) - confirm) - - currentDate <- date - - date <- - case frequency of - Punctual -> do - Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.Payment_Date - , Input._in_initialValue = currentDate - , Input._in_inputType = "date" - , Input._in_hasResetButton = False - , Input._in_validation = PaymentValidation.date - }) - (currentDate <$ reset) - confirm) - Monthly -> - return . R.constDyn $ currentDate - - setCategory <- - R.debounce (1 :: NominalDiffTime) (R.updated $ Input._out_raw name) - >>= (return . R.ffilter (\name -> T.length name >= 3)) - >>= (Ajax.get . (fmap ("/api/payment/category?name=" <>))) - >>= (return . R.mapMaybe (join . EitherUtil.eitherToMaybe)) - - category <- Select._out_value <$> (Select.view $ Select.In - { Select._in_label = Msg.get Msg.Payment_Category - , Select._in_initialValue = category - , Select._in_value = setCategory - , Select._in_values = R.constDyn categories - , Select._in_reset = category <$ reset - , Select._in_isValid = PaymentValidation.category (map _category_id $ _in_categories input) - , Select._in_validate = confirm - }) - - return $ do - n <- Input._out_value name - c <- cost - d <- date - cat <- category - return (mkPayload - <$> ValidationUtil.nelError n - <*> V.Success c - <*> V.Success d - <*> ValidationUtil.nelError cat - <*> V.Success frequency) - - frequencies = - M.fromList - [ (Punctual, Msg.get Msg.Payment_PunctualMale) - , (Monthly, Msg.get Msg.Payment_MonthlyMale) - ] - - categories = M.fromList . flip map (_in_categories input) $ \c -> - (_category_id c, _category_name c) - - category = - case op of - New -> -1 - Clone p -> _payment_category p - Edit p -> _payment_category p - - op = _in_operation input - - name = - case op of - New -> "" - Clone p -> _payment_name p - Edit p -> _payment_name p - - cost = - case op of - New -> "" - Clone p -> T.pack . show . _payment_cost $ p - Edit p -> T.pack . show . _payment_cost $ p - - date = do - currentDay <- liftIO $ Clock.getCurrentTime >>= TimeUtil.timeToDay - return . T.pack . Calendar.showGregorian $ - case op of - New -> currentDay - Clone p -> currentDay - Edit p -> _payment_date p - - frequency = - case op of - New -> _in_frequency input - Clone p -> _payment_frequency p - Edit p -> _payment_frequency p - - headerLabel = - case op of - New -> Msg.get Msg.Payment_Add - Clone _ -> Msg.get Msg.Payment_CloneLong - Edit _ -> Msg.get Msg.Payment_EditLong - - ajax = - case op of - Edit _ -> Ajax.put - _ -> Ajax.post - - mkPayload = - case op of - Edit p -> \a b c d e -> Aeson.toJSON $ EditPaymentForm (_payment_id p) a b c d e - _ -> \a b c d e -> Aeson.toJSON $ CreatePaymentForm a b c d e diff --git a/client/src/View/Payment/HeaderForm.hs b/client/src/View/Payment/HeaderForm.hs deleted file mode 100644 index 1915841..0000000 --- a/client/src/View/Payment/HeaderForm.hs +++ /dev/null @@ -1,85 +0,0 @@ -module View.Payment.HeaderForm - ( view - , In(..) - , Out(..) - ) where - -import qualified Data.Map as M -import Data.Text (Text) -import qualified Data.Validation as V -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Category, Currency, Frequency (..), - Income (..), Payment (..), User (..)) -import qualified Common.Msg as Msg - -import qualified Component.Button as Button -import qualified Component.Input as Input -import qualified Component.Modal as Modal -import qualified Component.Select as Select -import qualified Util.Reflex as ReflexUtil -import qualified View.Payment.Form as Form - -data In t = In - { _in_reset :: Event t () - , _in_categories :: [Category] - } - -data Out t = Out - { _out_search :: Event t Text - , _out_frequency :: Event t Frequency - , _out_addPayment :: Event t () - } - -view :: forall t m. MonadWidget t m => In t -> m (Out t) -view input = - R.divClass "g-PaymentHeaderForm" $ do - - (searchName, frequency) <- R.el "div" $ do - - searchName <- Input._out_raw <$> (Input.view - ( Input.defaultIn { Input._in_label = Msg.get Msg.Search_Name }) - ("" <$ _in_reset input) - R.never) - - let frequencies = M.fromList - [ (Punctual, Msg.get Msg.Payment_PunctualMale) - , (Monthly, Msg.get Msg.Payment_MonthlyMale) - ] - - frequency <- Select._out_raw <$> (Select.view $ Select.In - { Select._in_label = "" - , Select._in_initialValue = Punctual - , Select._in_value = R.never - , Select._in_values = R.constDyn frequencies - , Select._in_reset = R.never - , Select._in_isValid = V.Success - , Select._in_validate = R.never - }) - - return (searchName, frequency) - - addPaymentButton <- Button._out_clic <$> - (Button.view $ - (Button.defaultIn (R.text $ Msg.get Msg.Payment_Add)) - { Button._in_class = R.constDyn "addPayment" - }) - - addPayment <- - (R.dyn . R.ffor frequency $ \frequency -> - Modal.view $ Modal.In - { Modal._in_show = addPaymentButton - , Modal._in_content = - Form.view $ Form.In - { Form._in_categories = _in_categories input - , Form._in_operation = Form.New - , Form._in_frequency = frequency - } - }) >>= ReflexUtil.flatten - - return $ Out - { _out_search = R.updated searchName - , _out_frequency = R.updated frequency - , _out_addPayment = addPayment - } diff --git a/client/src/View/Payment/HeaderInfos.hs b/client/src/View/Payment/HeaderInfos.hs deleted file mode 100644 index f84ee1f..0000000 --- a/client/src/View/Payment/HeaderInfos.hs +++ /dev/null @@ -1,94 +0,0 @@ -module View.Payment.HeaderInfos - ( view - , In(..) - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.List as L hiding (groupBy) -import Data.Map (Map) -import qualified Data.Map as M -import Data.Maybe (fromMaybe) -import Data.Text (Text) -import qualified Data.Text as T -import qualified Data.Time as Time -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Currency, ExceedingPayer (..), - Payment (..), PaymentHeader (..), - User (..), UserId) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -data In t = In - { _in_users :: [User] - , _in_currency :: Currency - , _in_header :: PaymentHeader - , _in_paymentCount :: Int - } - -view :: forall t m. MonadWidget t m => In t -> m () -view input = - R.divClass "g-PaymentHeaderInfos" $ do - exceedingPayers - (_in_users input) - (_in_currency input) - (_paymentHeader_exceedingPayers header) - - infos - (_in_users input) - (_in_currency input) - (_paymentHeader_repartition header) - (_in_paymentCount input) - - where - header = _in_header input - -exceedingPayers - :: forall t m. MonadWidget t m - => [User] - -> Currency - -> [ExceedingPayer] - -> m () -exceedingPayers users currency payers = - R.divClass "g-PaymentHeaderInfos__ExceedingPayers" $ - flip mapM_ payers $ \payer -> - R.elClass "span" "exceedingPayer" $ do - R.elClass "span" "userName" $ - R.text $ - fromMaybe "" . fmap _user_name $ CM.findUser (_exceedingPayer_userId payer) users - R.elClass "span" "amount" $ do - R.text "+ " - R.text . Format.price currency $ _exceedingPayer_amount payer - -infos - :: forall t m. MonadWidget t m - => [User] - -> Currency - -> Map UserId Int - -> Int - -> m () -infos users currency repartition paymentCount = - R.divClass "g-PaymentHeaderInfos__Repartition" $ do - - R.elClass "span" "total" $ do - R.text $ - Msg.get $ Msg.Payment_Worth - (T.intercalate " " - [ (Format.number paymentCount) - , if paymentCount > 1 - then Msg.get Msg.Payment_Many - else Msg.get Msg.Payment_One - ]) - (Format.price currency (M.foldl (+) 0 repartition)) - - R.elClass "span" "partition" . R.text $ - let totalByUser = - L.sortBy (\(_, t1) (_, t2) -> compare t2 t1) - . M.toList - $ repartition - in T.intercalate ", " . flip map totalByUser $ \(userId, userTotal) -> - Msg.get $ Msg.Payment_By - (fromMaybe "" . fmap _user_name $ CM.findUser userId users) - (Format.price currency userTotal) diff --git a/client/src/View/Payment/Payment.hs b/client/src/View/Payment/Payment.hs deleted file mode 100644 index 26444d7..0000000 --- a/client/src/View/Payment/Payment.hs +++ /dev/null @@ -1,101 +0,0 @@ -module View.Payment.Payment - ( view - , In(..) - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Clock (NominalDiffTime) -import Prelude hiding (init) -import Reflex.Dom (Dynamic, Event, MonadWidget, Reflex) -import qualified Reflex.Dom as R - -import Common.Model (Currency, Frequency, Income (..), - Payment (..), PaymentId, - PaymentPage (..), User, UserId) -import qualified Common.Util.Text as T - -import qualified Component.Pages as Pages -import Loadable (Loadable (..)) -import qualified Loadable -import qualified Util.Ajax as AjaxUtil -import qualified Util.Reflex as ReflexUtil -import qualified View.Payment.HeaderForm as HeaderForm -import qualified View.Payment.HeaderInfos as HeaderInfos -import qualified View.Payment.Reducer as Reducer -import qualified View.Payment.Table as Table - -data In t = In - { _in_currentUser :: UserId - , _in_users :: [User] - , _in_currency :: Currency - } - -view :: forall t m. MonadWidget t m => In t -> m () -view input = do - - categories <- AjaxUtil.getNow "api/allCategories" - - R.dyn . R.ffor categories . Loadable.viewHideValueWhileLoading $ \categories -> do - - rec - paymentPage <- Reducer.reducer $ Reducer.In - { Reducer._in_page = page - , Reducer._in_search = HeaderForm._out_search form - , Reducer._in_frequency = HeaderForm._out_frequency form - , Reducer._in_addPayment = addPayment - , Reducer._in_editPayment = editPayment - , Reducer._in_deletePayment = deletePayment - } - - let eventFromResult :: forall a. ((Table.Out t, Pages.Out t) -> Event t a) -> m (Event t a) - eventFromResult op = ReflexUtil.flatten . fmap (Maybe.fromMaybe R.never . fmap op) $ result - - let addPayment = - R.leftmost - [ tableAddPayment - , HeaderForm._out_addPayment form - ] - - page <- eventFromResult $ Pages._out_newPage . snd - tableAddPayment <- eventFromResult $ Table._out_add . fst - editPayment <- eventFromResult $ Table._out_edit . fst - deletePayment <- eventFromResult $ Table._out_delete . fst - - form <- HeaderForm.view $ HeaderForm.In - { HeaderForm._in_reset = () <$ addPayment - , HeaderForm._in_categories = categories - } - - result <- Loadable.viewShowValueWhileLoading paymentPage $ - \(PaymentPage page frequency header payments count) -> do - - HeaderInfos.view $ HeaderInfos.In - { HeaderInfos._in_users = _in_users input - , HeaderInfos._in_currency = _in_currency input - , HeaderInfos._in_header = header - , HeaderInfos._in_paymentCount = count - } - - table <- Table.view $ Table.In - { Table._in_users = _in_users input - , Table._in_currentUser = _in_currentUser input - , Table._in_categories = categories - , Table._in_currency = _in_currency input - , Table._in_payments = payments - , Table._in_frequency = frequency - } - - pages <- Pages.view $ Pages.In - { Pages._in_total = R.constDyn count - , Pages._in_perPage = Reducer.perPage - , Pages._in_page = page - } - - return (table, pages) - - return () - - return () diff --git a/client/src/View/Payment/Reducer.hs b/client/src/View/Payment/Reducer.hs deleted file mode 100644 index 3fe59b2..0000000 --- a/client/src/View/Payment/Reducer.hs +++ /dev/null @@ -1,110 +0,0 @@ -module View.Payment.Reducer - ( perPage - , reducer - , In(..) - , Params(..) - ) where - -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time (NominalDiffTime) -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Frequency (..), PaymentPage) - -import Loadable (Loadable (..)) -import qualified Loadable as Loadable -import qualified Util.Ajax as AjaxUtil -import qualified Util.Either as EitherUtil - -perPage :: Int -perPage = 7 - -data In t a b c = In - { _in_page :: Event t Int - , _in_search :: Event t Text - , _in_frequency :: Event t Frequency - , _in_addPayment :: Event t a - , _in_editPayment :: Event t b - , _in_deletePayment :: Event t c - } - -data Params = Params - { _params_page :: Int - , _params_search :: Text - , _params_frequency :: Frequency - } deriving (Show) - -initParams = Params 1 "" Punctual - -data Msg - = Page Int - | Search Text - | Frequency Common.Model.Frequency - | ResetSearch - deriving Show - -reducer :: forall t m a b c. MonadWidget t m => In t a b c -> m (Dynamic t (Loadable PaymentPage)) -reducer input = do - - postBuild <- R.getPostBuild - - debouncedSearch <- R.debounce (1 :: NominalDiffTime) (_in_search input) - - params <- R.foldDynMaybe - (\msg params -> case msg of - Page page -> - Just $ params { _params_page = page } - - Search "" -> - if _params_search params == "" then - Nothing - - else - Just $ initParams { _params_frequency = _params_frequency params } - - Search search -> - Just $ params { _params_search = search, _params_page = _params_page initParams } - - Frequency frequency -> - Just $ params { _params_frequency = frequency, _params_page = _params_page initParams } - - ResetSearch -> - Just $ initParams { _params_frequency = _params_frequency params } - ) - initParams - (R.leftmost - [ Page <$> _in_page input - , Search <$> debouncedSearch - , Frequency <$> _in_frequency input - , ResetSearch <$ _in_addPayment input - ]) - - let paramsEvent = - R.leftmost - [ initParams <$ postBuild - , R.updated params - , R.tag (R.current params) (_in_editPayment input) - , R.tag (R.current params) (_in_deletePayment input) - ] - - getResult <- AjaxUtil.get (pageUrl <$> paramsEvent) - - R.holdDyn - Loading - (R.leftmost - [ Loading <$ paramsEvent - , Loadable.fromEither <$> getResult - ]) - - where - pageUrl (Params page search frequency) = - "api/payments?page=" - <> (T.pack . show $ page) - <> "&perPage=" - <> (T.pack . show $ perPage) - <> "&search=" - <> search - <> "&frequency=" - <> (T.pack $ show frequency) diff --git a/client/src/View/Payment/Table.hs b/client/src/View/Payment/Table.hs deleted file mode 100644 index 66065af..0000000 --- a/client/src/View/Payment/Table.hs +++ /dev/null @@ -1,145 +0,0 @@ -module View.Payment.Table - ( view - , In(..) - , Out(..) - ) where - -import qualified Data.List as L -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Reflex.Dom (Dynamic, Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Category (..), Currency, - Frequency (..), Payment (..), - User (..), UserId) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -import qualified Component.ConfirmDialog as ConfirmDialog -import qualified Component.Table as Table -import qualified Component.Tag as Tag -import qualified Util.Ajax as Ajax -import qualified Util.Either as EitherUtil -import qualified View.Payment.Form as Form - -data In t = In - { _in_users :: [User] - , _in_currentUser :: UserId - , _in_categories :: [Category] - , _in_currency :: Currency - , _in_payments :: [Payment] - , _in_frequency :: Frequency - } - -data Out t = Out - { _out_add :: Event t () - , _out_edit :: Event t () - , _out_delete :: Event t () - } - -view :: forall t m. MonadWidget t m => In t -> m (Out t) -view input = do - - table <- Table.view $ Table.In - { Table._in_headerLabel = headerLabel (_in_frequency input) - , Table._in_rows = _in_payments input - , Table._in_cell = - cell - (_in_users input) - (_in_categories input) - (_in_frequency input) - (_in_currency input) - , Table._in_cloneModal = \payment -> - Form.view $ Form.In - { Form._in_categories = _in_categories input - , Form._in_operation = Form.Clone payment - , Form._in_frequency = _in_frequency input - } - , Table._in_editModal = \payment -> - Form.view $ Form.In - { Form._in_categories = _in_categories input - , Form._in_operation = Form.Edit payment - , Form._in_frequency = _in_frequency input - } - , Table._in_deleteModal = \payment -> - ConfirmDialog.view $ ConfirmDialog.In - { ConfirmDialog._in_header = Msg.get Msg.Payment_DeleteConfirm - , ConfirmDialog._in_confirm = \e -> do - res <- Ajax.delete - (R.constDyn $ T.concat ["/api/payment/", T.pack . show $ _payment_id payment]) - e - return $ () <$ R.fmapMaybe EitherUtil.eitherToMaybe res - } - , Table._in_canEdit = (== (_in_currentUser input)) . _payment_user - , Table._in_canDelete = (== (_in_currentUser input)) . _payment_user - } - - return $ Out - { _out_add = Table._out_add table - , _out_edit = Table._out_edit table - , _out_delete = Table._out_delete table - } - -data Header - = NameHeader - | CostHeader - | UserHeader - | CategoryHeader - | DateHeader - deriving (Eq, Show, Bounded, Enum) - -headerLabel :: Frequency -> Header -> Text -headerLabel _ NameHeader = Msg.get Msg.Payment_Name -headerLabel _ CostHeader = Msg.get Msg.Payment_Cost -headerLabel _ UserHeader = Msg.get Msg.Payment_User -headerLabel _ CategoryHeader = Msg.get Msg.Payment_Category -headerLabel Punctual DateHeader = Msg.get Msg.Payment_Date -headerLabel Monthly DateHeader = "" - -cell - :: forall t m. MonadWidget t m - => [User] - -> [Category] - -> Frequency - -> Currency - -> Header - -> Payment - -> m () -cell users categories frequency currency header payment = - case header of - NameHeader -> - R.text $ _payment_name payment - - CostHeader -> - R.divClass (if amount < 0 then "g-Payment__Refund" else "") $ - R.text $ Format.price currency amount - where amount = _payment_cost payment - - UserHeader -> - R.text . Maybe.fromMaybe "" . fmap _user_name $ CM.findUser (_payment_user payment) users - - CategoryHeader -> - let - category = - L.find ((== (_payment_category payment)) . _category_id) categories - in - Maybe.fromMaybe R.blank . flip fmap category $ \c -> - Tag.view $ Tag.In - { Tag._in_text = _category_name c - , Tag._in_color = _category_color c - } - - DateHeader -> - if frequency == Punctual then - do - R.elClass "span" "shortDate" $ - R.text . Format.shortDay . _payment_date $ payment - - R.elClass "span" "longDate" $ - R.text . Format.longDay . _payment_date $ payment - else - R.blank diff --git a/client/src/View/SignIn.hs b/client/src/View/SignIn.hs deleted file mode 100644 index e68755f..0000000 --- a/client/src/View/SignIn.hs +++ /dev/null @@ -1,82 +0,0 @@ -module View.SignIn - ( view - , Out(..) - ) where - -import qualified Data.Either as Either -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Validation as V -import Reflex.Dom (Event, MonadWidget) -import qualified Reflex.Dom as R - -import Common.Model (Init, SignInForm (SignInForm)) -import qualified Common.Msg as Msg -import qualified Common.Validation.SignIn as SignInValidation - -import qualified Component.Button as Button -import qualified Component.Form as Form -import qualified Component.Input as Input -import qualified Util.Ajax as Ajax -import qualified Util.Validation as ValidationUtil -import qualified Util.WaitFor as WaitFor - -data Out t = Out - { _out_success :: Event t Init - } - -view :: forall t m. MonadWidget t m => m (Out t) -view = do - signInResult <- R.divClass "signIn" $ - Form.view $ do - rec - let resetForm = ("" <$ R.ffilter Either.isRight signInResult) - - email <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.SignIn_EmailLabel - , Input._in_validation = SignInValidation.email - }) - resetForm - validate) - - password <- Input._out_raw <$> (Input.view - (Input.defaultIn - { Input._in_label = Msg.get Msg.SignIn_PasswordLabel - , Input._in_validation = SignInValidation.password - , Input._in_inputType = "password" - }) - resetForm - validate) - - validate <- Button._out_clic <$> (Button.view $ - (Button.defaultIn (R.text $ Msg.get Msg.SignIn_Button)) - { Button._in_class = R.constDyn "validate" - , Button._in_waiting = waiting - , Button._in_submit = True - }) - - let form = do - e <- email - p <- password - return . V.Success $ SignInForm e p - - (signInResult, waiting) <- WaitFor.waitFor - (Ajax.postAndParseResult "/api/signIn") - (ValidationUtil.fireValidation form validate) - - showSignInResult signInResult - - return signInResult - - return $ Out - { _out_success = R.filterRight signInResult - } - -showSignInResult :: forall t m. MonadWidget t m => Event t (Either Text Init) -> m () -showSignInResult signInResult = do - _ <- R.widgetHold R.blank $ showResult <$> signInResult - R.blank - - where showResult (Left error) = R.divClass "error" . R.text $ error - showResult (Right _) = R.blank diff --git a/client/src/View/Statistics/Chart.hs b/client/src/View/Statistics/Chart.hs deleted file mode 100644 index 63df2a1..0000000 --- a/client/src/View/Statistics/Chart.hs +++ /dev/null @@ -1,102 +0,0 @@ -{-# LANGUAGE CPP #-} -{-# LANGUAGE JavaScriptFFI #-} - -module View.Statistics.Chart - ( view - , In(..) - , Dataset(..) - ) where - -import qualified Control.Concurrent as Concurrent -import Control.Monad (void) -import Control.Monad.IO.Class (liftIO) -import Data.Aeson ((.=)) -import qualified Data.Aeson as AE -import qualified Data.Map as M -import Data.Text (Text) -import Language.Javascript.JSaddle (JSString, JSVal) -import qualified Language.Javascript.JSaddle.Value as JSValue -import Reflex.Dom (MonadWidget) -import qualified Reflex.Dom as R --- import GHCJS.Foreign.Callback - - -#ifdef __GHCJS__ -foreign import javascript unsafe "new Chart(document.getElementById($1), $2);" drawChart :: JSString -> JSVal -> IO () -#else -drawChart = error "drawChart: only available from JavaScript" -#endif - -data In = In - { _in_title :: Text - , _in_labels :: [Text] - , _in_datasets :: [Dataset] - } - -data Dataset = Dataset - { _dataset_label :: Text - , _dataset_data :: [Int] - , _dataset_color :: Text - } - -view :: forall t m. MonadWidget t m => In -> m () -view input = do - R.divClass "g-Chart" $ - R.elAttr "canvas" (M.singleton "id" "chart") $ - R.blank - - liftIO $ Concurrent.forkIO $ do - Concurrent.threadDelay 500000 - config <- JSValue.valMakeJSON (configToJson input) - drawChart "chart" config - - return () - -configToJson (In title labels datasets) = - AE.object - [ "type" .= AE.String "line" - , "data" .= - AE.object - [ "labels" .= labels - , "datasets" .= map datasetToJson datasets - ] - , "options" .= - AE.object - [ "responsive" .= True - , "title" .= - AE.object - [ "display" .= True - , "text" .= title - ] - , "tooltips" .= - AE.object - [ "mode" .= AE.String "nearest" - , "intersect" .= False - ] - , "hover" .= - AE.object - [ "mode" .= AE.String "nearest" - , "intersect" .= True - ] - , "scales" .= - AE.object - [ "yAxes" .= - [ [ AE.object - [ "ticks" .= - AE.object - [ "beginAtZero" .= True ] - ] - ] - ] - ] - ] - ] - -datasetToJson (Dataset label data_ color) = - AE.object - [ "label" .= label - , "data" .= data_ - , "fill" .= False - , "backgroundColor" .= color - , "borderColor" .= color - ] diff --git a/client/src/View/Statistics/Statistics.hs b/client/src/View/Statistics/Statistics.hs deleted file mode 100644 index d931b2b..0000000 --- a/client/src/View/Statistics/Statistics.hs +++ /dev/null @@ -1,85 +0,0 @@ -module View.Statistics.Statistics - ( view - , In(..) - ) where - -import Control.Monad (void) -import Data.Map (Map) -import qualified Data.Map as M -import qualified Data.Text as T -import Data.Time.Calendar (Day) -import qualified Data.Time.Calendar as Calendar -import Loadable (Loadable) -import qualified Loadable -import Reflex.Dom (Dynamic, MonadWidget) -import qualified Reflex.Dom as R -import qualified Util.Ajax as AjaxUtil -import qualified View.Statistics.Chart as Chart - -import Common.Model (Category (..), Currency, Income, - MonthStats (..), Stats, User (..)) -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -data In = In - { _in_currency :: Currency - } - -view :: forall t m. MonadWidget t m => In -> m () -view input = do - - users <- AjaxUtil.getNow "api/users" - categories <- AjaxUtil.getNow "api/allCategories" - statistics <- AjaxUtil.getNow "api/statistics" - - let loadable = (\u c s -> (,,) <$> u <*> c <*> s) <$> users <*> categories <*> statistics - - R.divClass "withMargin" $ - R.divClass "titleButton" $ - R.el "h1" $ - R.text $ Msg.get Msg.Statistics_Title - - void . R.dyn . R.ffor loadable . Loadable.viewHideValueWhileLoading $ - stats (_in_currency input) - -stats :: forall t m. MonadWidget t m => Currency -> ([User], [Category], Stats) -> m () -stats currency (users, categories, stats) = - Chart.view $ Chart.In - { Chart._in_title = Msg.get (Msg.Statistics_ByMonthsAndMean averagePayment averageIncome) - , Chart._in_labels = map (Format.monthAndYear . _monthStats_start) stats - , Chart._in_datasets = totalIncomeDataset : totalPaymentDataset : (map categoryDataset categories) - } - - where - averageIncome = - Format.price currency $ sum totalIncomes `div` length stats - - totalIncomeDataset = - Chart.Dataset - { Chart._dataset_label = Msg.get Msg.Statistics_TotalIncomes - , Chart._dataset_data = totalIncomes - , Chart._dataset_color = "#222222" - } - - totalIncomes = - map (sum . map snd . M.toList . _monthStats_incomeByUser) stats - - averagePayment = - Format.price currency $ sum totalPayments `div` length stats - - totalPaymentDataset = - Chart.Dataset - { Chart._dataset_label = Msg.get Msg.Statistics_TotalPayments - , Chart._dataset_data = totalPayments - , Chart._dataset_color = "#555555" - } - - totalPayments = - map (sum . map snd . M.toList . _monthStats_paymentsByCategory) stats - - categoryDataset category = - Chart.Dataset - { Chart._dataset_label = _category_name category - , Chart._dataset_data = map (M.findWithDefault 0 (_category_id category) . _monthStats_paymentsByCategory) stats - , Chart._dataset_color = _category_color category - } diff --git a/common/LICENSE b/common/LICENSE deleted file mode 100644 index 45644ff..0000000 --- a/common/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/common/Setup.hs b/common/Setup.hs deleted file mode 100644 index 4467109..0000000 --- a/common/Setup.hs +++ /dev/null @@ -1,2 +0,0 @@ -import Distribution.Simple -main = defaultMain diff --git a/common/common.cabal b/common/common.cabal deleted file mode 100644 index dffc8d0..0000000 --- a/common/common.cabal +++ /dev/null @@ -1,72 +0,0 @@ -Name: common -Version: 0.0.1 -License: GPL-3 -License-file: LICENSE -Author: Joris Guyonvarch -Maintainer: joris@guyonvarch.me -Category: Web -Build-type: Simple -Cabal-version: >=1.10 - -Library - Ghc-options: -Wall -Werror - Hs-source-dirs: src - Default-language: Haskell2010 - - Default-extensions: - DeriveGeneric - ExistentialQuantification - LambdaCase - MultiParamTypeClasses - OverloadedStrings - ScopedTypeVariables - - Build-depends: - aeson - , base >= 4.11 && < 5 - , containers - , text - , time - , validation - - Exposed-modules: - Common.Model - Common.Model.CreateCategoryForm - Common.Model.CreateIncomeForm - Common.Model.CreatePaymentForm - Common.Model.Email - Common.Model.Password - Common.Model.Payment - Common.Model.SignInForm - Common.Model.User - Common.Msg - Common.Util.Text - Common.Util.Time - Common.Util.Validation - Common.Validation.Atomic - Common.Validation.Category - Common.Validation.Income - Common.Validation.Payment - Common.Validation.SignIn - Common.View.Format - - other-modules: - Common.Message.Key - Common.Message.Lang - Common.Message.Translation - Common.Model.Category - Common.Model.CategoryPage - Common.Model.Currency - Common.Model.EditCategoryForm - Common.Model.EditIncome - Common.Model.EditIncomeForm - Common.Model.EditPaymentForm - Common.Model.ExceedingPayer - Common.Model.Frequency - Common.Model.Income - Common.Model.IncomeHeader - Common.Model.IncomePage - Common.Model.Init - Common.Model.PaymentHeader - Common.Model.PaymentPage - Common.Model.Stats diff --git a/common/src/Common/Message/Key.hs b/common/src/Common/Message/Key.hs deleted file mode 100644 index f3b0837..0000000 --- a/common/src/Common/Message/Key.hs +++ /dev/null @@ -1,150 +0,0 @@ -module Common.Message.Key - ( Key(..) - ) where - -import Data.Text - -data Key = - - App_Title - - | Category_Add - | Category_Clone - | Category_Color - | Category_DeleteConfirm - | Category_Edit - | Category_Empty - | Category_Name - | Category_NotDeleted - | Category_Title - | Category_Used - - | Date_Long Int Text Int - | Date_Short Int Int Int - | Date_ShortMonthAndYear Int Int - - | Dialog_Confirm - | Dialog_Undo - - | Error_CategoryCreate - | Error_CategoryDelete - | Error_CategoryEdit - | Error_IncomeCreate - | Error_IncomeDelete - | Error_IncomeEdit - | Error_PaymentCreate - | Error_PaymentDelete - | Error_PaymentEdit - | Error_SignOut - - | Form_AlreadyExists - | Form_NonEmpty - | Form_MinChars Int - | Form_NonNullNumber - | Form_GreaterIntThan Int - | Form_InvalidCategory - | Form_InvalidColor - | Form_InvalidDate - | Form_InvalidInt - | Form_InvalidString - | Form_SmallerIntThan Int - - | HttpError_BadPayload - | HttpError_BadUrl - | HttpError_NetworkError - | HttpError_Timeout - - | Income_AddLong - | Income_AddShort - | Income_Name - | Income_Amount - | Income_Clone - | Income_CumulativeSince Text - | Income_Date - | Income_DeleteConfirm - | Income_Edit - | Income_Empty - | Income_MonthlyNet - | Income_Title - - | Month_January - | Month_February - | Month_March - | Month_April - | Month_May - | Month_June - | Month_July - | Month_August - | Month_September - | Month_October - | Month_November - | Month_December - - | PageNotFound_Title - - | Payment_Add - | Payment_Balanced - | Payment_By Text Text - | Payment_Category - | Payment_CloneLong - | Payment_CloneShort - | Payment_Cost - | Payment_Date - | Payment_Delete - | Payment_DeleteConfirm - | Payment_EditLong - | Payment_EditShort - | Payment_Empty - | Payment_Frequency - | Payment_InvalidFrequency - | Payment_Many - | Payment_MonthlyFemale - | Payment_MonthlyMale - | Payment_Name - | Payment_NotDeleted - | Payment_One - | Payment_PunctualFemale - | Payment_PunctualMale - | Payment_Title - | Payment_User - | Payment_Worth Text Text - - | Search_Monthly - | Search_Name - | Search_Punctual - - | Secure_Forbidden - | Secure_Unauthorized - - | SignIn_Button - | SignIn_DisconnectSuccess - | SignIn_InvalidCredentials - | SignIn_EmailLabel - | SignIn_PasswordLabel - - | Statistics_Title - | Statistics_ByMonthsAndMean Text Text - | Statistics_TotalPayments - | Statistics_TotalIncomes - - | WeeklyReport_Empty - | WeeklyReport_IncomesCreated Int - | WeeklyReport_IncomesDeleted Int - | WeeklyReport_IncomesEdited Int - | WeeklyReport_IncomeCreated Int - | WeeklyReport_IncomeDeleted Int - | WeeklyReport_IncomeEdited Int - | WeeklyReport_PayedFor Text Text Text Text - | WeeklyReport_PayedForNot Text Text Text Text - | WeeklyReport_PayedFrom Text Text Text - | WeeklyReport_PayedFromNot Text Text Text - | WeeklyReport_PaymentsCreated Int - | WeeklyReport_PaymentsDeleted Int - | WeeklyReport_PaymentsEdited Int - | WeeklyReport_PaymentCreated Int - | WeeklyReport_PaymentDeleted Int - | WeeklyReport_PaymentEdited Int - | WeeklyReport_Title - - | NotFound_Message - | NotFound_LinkMessage diff --git a/common/src/Common/Message/Lang.hs b/common/src/Common/Message/Lang.hs deleted file mode 100644 index 0a32ede..0000000 --- a/common/src/Common/Message/Lang.hs +++ /dev/null @@ -1,7 +0,0 @@ -module Common.Message.Lang - ( Lang(..) - ) where - -data Lang = - English - | French diff --git a/common/src/Common/Message/Translation.hs b/common/src/Common/Message/Translation.hs deleted file mode 100644 index f1de3e1..0000000 --- a/common/src/Common/Message/Translation.hs +++ /dev/null @@ -1,655 +0,0 @@ -module Common.Message.Translation - ( get - ) where - -import Data.Text (Text) -import qualified Data.Text as T - -import Common.Message.Key -import Common.Message.Lang (Lang (..)) - -get :: Lang -> Key -> Text -get = m - -m :: Lang -> Key -> Text - -m l App_Title = - case l of - English -> "Shared Cost" - French -> "Partage des frais" - -m l Category_Add = - case l of - English -> "Add an category" - French -> "Ajouter une catégorie" - -m l Category_Clone = - case l of - English -> "Clone an category" - French -> "Cloner une catégorie" - -m l Category_Color = - case l of - English -> "Color" - French -> "Couleur" - -m l Category_DeleteConfirm = - case l of - English -> "Are you sure to delete this category ?" - French -> "Voulez-vous vraiment supprimer cette catégorie ?" - -m l Category_Edit = - case l of - English -> "Edit an category" - French -> "Modifier une catégorie" - -m l Category_Empty = - case l of - English -> "No category." - French -> "Aucune catégorie." - -m l Category_Name = - case l of - English -> "Name" - French -> "Nom" - -m l Category_NotDeleted = - case l of - English -> "The category could not have been deleted." - French -> "La catégorie n’a pas pu être supprimé." - -m l Category_Title = - case l of - English -> "Categories" - French -> "Catégories" - -m l Category_Used = - case l of - English -> "This category is currently being used" - French -> "Cette catégorie est actuellement utilisée" - -m l (Date_Short day month year) = - case l of - English -> - T.intercalate "-" [ padded year 4, padded month 2, padded day 2 ] - French -> - T.intercalate "/" [ padded day 2, padded month 2, padded year 4 ] - where padded num pad = - let str = show num - in T.pack $ replicate (pad - length str) '0' ++ str - -m l (Date_ShortMonthAndYear month year) = - case l of - English -> - T.intercalate "-" . map (T.pack . show) $ [ year, month ] - French -> - T.intercalate "/" . map (T.pack . show) $ [ month, year ] - -m l (Date_Long day month year) = - case l of - English -> - T.concat [ month, " " , T.pack . show $ day, ", ", T.pack . show $ year ] - French -> - T.intercalate " " [ T.pack . show $ day, month, T.pack . show $ year ] - -m l Dialog_Confirm = - case l of - English -> "Confirm" - French -> "Confirmer" - -m l Dialog_Undo = - case l of - English -> "Undo" - French -> "Annuler" - -m l Error_CategoryCreate = - case l of - English -> "Error at category creation" - French -> "Erreur lors de la création de la catégorie" - -m l Error_CategoryDelete = - case l of - English -> "Error at category deletion" - French -> "Erreur lors de la suppression de la catégorie" - -m l Error_CategoryEdit = - case l of - English -> "Error at category edition" - French -> "Erreur lors de la modification de la catégorie" - -m l Error_IncomeCreate = - case l of - English -> "Error at income creation" - French -> "Erreur lors de la création du revenu" - -m l Error_IncomeDelete = - case l of - English -> "Error at income deletion" - French -> "Erreur lors de la suppression du revenu" - -m l Error_IncomeEdit = - case l of - English -> "Error at income edition" - French -> "Erreur lors de la modification du revenu" - -m l Error_PaymentCreate = - case l of - English -> "Error at payment creation" - French -> "Erreur lors de la création du paiement" - -m l Error_PaymentDelete = - case l of - English -> "Error at payment deletion" - French -> "Erreur lors de la suppression du paiement" - -m l Error_PaymentEdit = - case l of - English -> "Error at payment edition" - French -> "Erreur lors de la modification du paiement" - -m l Error_SignOut = - case l of - English -> "Error at sign out" - French -> "Erreur lors de la déconnexion" - -m l Form_AlreadyExists = - case l of - English -> "Dupplicate field" - French -> "Doublon" - -m l Form_NonEmpty = - case l of - English -> "Required field" - French -> "Champ requis" - -m l (Form_MinChars number) = - case l of - English -> T.concat [ "This field must contains at least ", T.pack . show $ number, " characters" ] - French -> T.concat [ "Ce champ doit contenir au moins ", T.pack . show $ number, " caractères" ] - -m l Form_NonNullNumber = - case l of - English -> "Number must not be null" - French -> "Le nombre ne doit pas être nul" - -m l (Form_GreaterIntThan number) = - case l of - English -> T.concat [ "Integer smaller than ", T.pack . show $ number, " or equal required" ] - French -> T.concat [ "Entier inférieur ou égal à ", T.pack . show $ number, " requis" ] - -m l Form_InvalidCategory = - case l of - English -> "Invalid category" - French -> "Catégorie invalide" - -m l Form_InvalidColor = - case l of - English -> "Invalid color" - French -> "Couleur invalide" - -m l Form_InvalidDate = - case l of - English -> "Date required" - French -> "Date requise" - -m l Form_InvalidInt = - case l of - English -> "Integer required" - French -> "Entier requis" - -m l Form_InvalidString = - case l of - English -> "String required" - French -> "Chaîne de caractères requise" - -m l (Form_SmallerIntThan number) = - case l of - English -> T.concat [ "Integer bigger than ", T.pack . show $ number, " or equal required" ] - French -> T.concat [ "Entier supérieur ou égal à ", T.pack . show $ number, " requis" ] - -m l HttpError_BadPayload = - case l of - English -> "Bad payload server error" - French -> "Contenu inattendu en provenance du serveur" - -m l HttpError_BadUrl = - case l of - English -> "URL not valid" - French -> "l’URL n’est pas valide" - -m l HttpError_NetworkError = - case l of - English -> "Network can not be reached" - French -> "Le serveur n’est pas accessible" - -m l HttpError_Timeout = - case l of - English -> "Timeout server error" - French -> "Le serveur met trop de temps à répondre" - -m l Income_AddLong = - case l of - English -> "Add an income" - French -> "Ajouter un revenu" - -m l Income_AddShort = - case l of - English -> "Add" - French -> "Ajouter" - -m l Income_Name = - case l of - English -> "Name" - French -> "Nom" - -m l Income_Amount = - case l of - English -> "Income" - French -> "Revenu" - -m l Income_Clone = - case l of - English -> "Clone an income" - French -> "Cloner un revenu" - -m l (Income_CumulativeSince since) = - case l of - English -> T.concat [ "Cumulative incomes since ", since ] - French -> T.concat [ "Revenus nets cumulés depuis le ", since ] - -m l Income_Date = - case l of - English -> "Date" - French -> "Date" - -m l Income_DeleteConfirm = - case l of - English -> "Are you sure to delete this income ?" - French -> "Voulez-vous vraiment supprimer ce revenu ?" - -m l Income_Edit = - case l of - English -> "Edit an income" - French -> "Modifier un revenu" - -m l Income_Empty = - case l of - English -> "No income." - French -> "Aucun revenu." - -m l Income_MonthlyNet = - case l of - English -> "Net monthly incomes" - French -> "Revenus mensuels nets" - -m l Income_Title = - case l of - English -> "Income" - French -> "Revenu" - -m l Month_January = - case l of - English -> "january" - French -> "janvier" - -m l Month_February = - case l of - English -> "february" - French -> "février" - -m l Month_March = - case l of - English -> "march" - French -> "mars" - -m l Month_April = - case l of - English -> "april" - French -> "avril" - -m l Month_May = - case l of - English -> "may" - French -> "mai" - -m l Month_June = - case l of - English -> "june" - French -> "juin" - -m l Month_July = - case l of - English -> "july" - French -> "juillet" - -m l Month_August = - case l of - English -> "august" - French -> "août" - -m l Month_September = - case l of - English -> "september" - French -> "septembre" - -m l Month_October = - case l of - English -> "october" - French -> "octobre" - -m l Month_November = - case l of - English -> "november" - French -> "novembre" - -m l Month_December = - case l of - English -> "december" - French -> "décembre" - -m l PageNotFound_Title = - case l of - English -> "Page not found" - French -> "Page introuvable" - -m l Payment_Add = - case l of - English -> "Add a payment" - French -> "Ajouter un paiement" - -m l Payment_Balanced = - case l of - English -> "Payments are balanced." - French -> "Les paiements sont équilibrés." - -m l (Payment_By key value) = - case l of - English -> T.concat [ key, ": ", value ] - French -> T.concat [ key, " : ", value ] - -m l Payment_Category = - case l of - English -> "Category" - French -> "Catégorie" - -m l Payment_CloneLong = - case l of - English -> "Clone a payment" - French -> "Cloner un paiement" - -m l Payment_CloneShort = - case l of - English -> "Clone" - French -> "Cloner" - -m l Payment_Cost = - case l of - English -> "Cost" - French -> "Coût" - -m l Payment_Date = - case l of - English -> "Date" - French -> "Date" - -m l Payment_Delete = - case l of - English -> "Delete" - French -> "Supprimer" - -m l Payment_DeleteConfirm = - case l of - English -> "Are you sure to delete this payment ?" - French -> "Voulez-vous vraiment supprimer ce paiement ?" - -m l Payment_EditLong = - case l of - English -> "Edit a payment" - French -> "Modifier un paiement" - -m l Payment_EditShort = - case l of - English -> "Edit" - French -> "Modifier" - -m l Payment_Empty = - case l of - English -> "No payment found from your search criteria." - French -> "Aucun paiement ne correspond à vos critères de recherches." - -m l Payment_Frequency = - case l of - English -> "Frequency" - French -> "Fréquence" - -m l Payment_InvalidFrequency = - case l of - English -> "Invalid frequency" - French -> "Fréquence invalide" - -m l Payment_Many = - case l of - English -> "payments" - French -> "paiements" - -m l Payment_MonthlyFemale = - case l of - English -> "Monthly" - French -> "Mensuelle" - -m l Payment_MonthlyMale = - case l of - English -> "Monthly" - French -> "Mensuel" - -m l Payment_Name = - case l of - English -> "Name" - French -> "Nom" - -m l Payment_NotDeleted = - case l of - English -> "The payment could not have been deleted." - French -> "Le paiement n’a pas pu être supprimé." - -m l Payment_One = - case l of - English -> "payment" - French -> "paiement" - -m l Payment_PunctualFemale = - case l of - English -> "Punctual" - French -> "Ponctuelle" - -m l Payment_PunctualMale = - case l of - English -> "Punctual" - French -> "Ponctuel" - -m l Payment_Title = - case l of - English -> "Payments" - French -> "Paiements" - -m l Payment_User = - case l of - English -> "Payer" - French -> "Payeur" - -m l (Payment_Worth subject amount) = - case l of - English -> T.concat [ subject, " worth ", amount ] - French -> T.concat [ subject, " comptabilisant ", amount ] - -m l Search_Monthly = - case l of - English -> "Monthly" - French -> "Mensuel" - -m l Search_Name = - case l of - English -> "Search" - French -> "Recherche" - -m l Search_Punctual = - case l of - English -> "Punctual" - French -> "Ponctuel" - -m l Secure_Unauthorized = - case l of - English -> "You are not authorized to sign in." - French -> "Tu n’es pas autorisé à te connecter." - -m l Secure_Forbidden = - case l of - English -> "You need to be logged in to perform this action" - French -> "Tu dois te connecter pour effectuer cette action" - -m l SignIn_Button = - case l of - English -> "Sign in" - French -> "Connexion" - -m l SignIn_DisconnectSuccess = - case l of - English -> "You have successfully disconnected" - French -> "Vous êtes à présent déconnecté." - -m l SignIn_InvalidCredentials = - case l of - English -> "Your credentials are not valid." - French -> "Vos identifiants de connexion ne sont pas valides." - -m l SignIn_EmailLabel = - case l of - English -> "Email" - French -> "Courriel" - -m l SignIn_PasswordLabel = - case l of - English -> "Password" - French -> "Mot de passe" - -m l (Statistics_ByMonthsAndMean paymentMean incomeMean ) = - case l of - English -> - T.concat [ "Payments by category (mean ", paymentMean, ") and income (mean ", incomeMean, ") by month" ] - French -> - T.concat [ "Paiements par catégorie (moy. ", paymentMean, ") et revenu (moy. ", incomeMean, ") par mois" ] - -m l Statistics_Title = - case l of - English -> "Statistics" - French -> "Statistiques" - -m l Statistics_TotalPayments = - case l of - English -> "Payment total" - French -> "Total des paiements" - -m l Statistics_TotalIncomes = - case l of - English -> "Income total" - French -> "Total des revenus" - -m l WeeklyReport_Empty = - case l of - English -> "No activity the previous week." - French -> "Pas d’activité la semaine passée." - -m l (WeeklyReport_IncomesCreated count) = - case l of - English -> T.concat [ T.pack . show $ count, " incomes created:" ] - French -> T.concat [ T.pack . show $ count, " revenus créés :" ] - -m l (WeeklyReport_IncomesDeleted count) = - case l of - English -> T.concat [ T.pack . show $ count, " incomes deleted:" ] - French -> T.concat [ T.pack . show $ count, " revenus supprimés :" ] - -m l (WeeklyReport_IncomesEdited count) = - case l of - English -> T.concat [ T.pack . show $ count, " incomes edited:" ] - French -> T.concat [ T.pack . show $ count, " revenus modifiés :" ] - -m l (WeeklyReport_IncomeCreated count) = - case l of - English -> T.concat [ T.pack . show $ count, " income created:" ] - French -> T.concat [ T.pack . show $ count, " revenu créé :" ] - -m l (WeeklyReport_IncomeDeleted count) = - case l of - English -> T.concat [ T.pack . show $ count, " income deleted:" ] - French -> T.concat [ T.pack . show $ count, " revenu supprimé :" ] - -m l (WeeklyReport_IncomeEdited count) = - case l of - English -> T.concat [ T.pack . show $ count, " income edited:" ] - French -> T.concat [ T.pack . show $ count, " revenu modifié :" ] - -m l (WeeklyReport_PayedFor name amount for at) = - case l of - English -> T.concat [ name, " payed ", amount, " for “", for, "” at ", at ] - French -> T.concat [ name, " a payé ", amount, " de « ", for, " » le ", at ] - -m l (WeeklyReport_PayedForNot name amount for at) = - case l of - English -> T.concat [ name, " didn’t pay ", amount, " for “", for, "” at ", at ] - French -> T.concat [ name, " n’a pas payé ", amount, " de « ", for, " » le ", at ] - -m l (WeeklyReport_PayedFrom name amount for) = - case l of - English -> T.concat [ name, " is payed ", amount, " of net monthly income from ", for ] - French -> T.concat [ name, " est payé ", amount, " net par mois à partir du ", for ] - -m l (WeeklyReport_PayedFromNot name amount for) = - case l of - English -> T.concat [ name, " isn’t payed ", amount, " of net monthly income from ", for ] - French -> T.concat [ name, " n’est pas payé ", amount, " net par mois à partir du ", for ] - -m l (WeeklyReport_PaymentsCreated count) = - case l of - English -> T.concat [ T.pack . show $ count, " payments created:" ] - French -> T.concat [ T.pack . show $ count, " paiements créés :" ] - -m l (WeeklyReport_PaymentsDeleted count) = - case l of - English -> T.concat [ T.pack . show $ count, " payments deleted:" ] - French -> T.concat [ T.pack . show $ count, " paiements supprimés :" ] - -m l (WeeklyReport_PaymentsEdited count) = - case l of - English -> T.concat [ T.pack . show $ count, " payments edited:" ] - French -> T.concat [ T.pack . show $ count, " paiements modifiés :" ] - -m l (WeeklyReport_PaymentCreated count) = - case l of - English -> T.concat [ T.pack . show $ count, " payment created:" ] - French -> T.concat [ T.pack . show $ count, " paiement créé :" ] - -m l (WeeklyReport_PaymentDeleted count) = - case l of - English -> T.concat [ T.pack . show $ count, " payment deleted:" ] - French -> T.concat [ T.pack . show $ count, " paiement supprimé :" ] - -m l (WeeklyReport_PaymentEdited count) = - case l of - English -> T.concat [ T.pack . show $ count, " payment edited:" ] - French -> T.concat [ T.pack . show $ count, " paiement modifié :" ] - -m l WeeklyReport_Title = - case l of - English -> "Weekly report" - French -> "Rapport hebdomadaire" - -m l NotFound_Message = - case l of - English -> "There is nothing here!" - French -> "Il n’y a rien à voir ici." - -m l NotFound_LinkMessage = - case l of - English -> "Go back to the home page." - French -> "Retour à l’accueil." diff --git a/common/src/Common/Model.hs b/common/src/Common/Model.hs deleted file mode 100644 index 979d876..0000000 --- a/common/src/Common/Model.hs +++ /dev/null @@ -1,26 +0,0 @@ -module Common.Model (module X) where - -import Common.Model.Category as X -import Common.Model.CategoryPage as X -import Common.Model.CreateCategoryForm as X -import Common.Model.CreateIncomeForm as X -import Common.Model.CreatePaymentForm as X -import Common.Model.Currency as X -import Common.Model.EditCategoryForm as X -import Common.Model.EditIncome as X -import Common.Model.EditIncomeForm as X -import Common.Model.EditPaymentForm as X -import Common.Model.Email as X -import Common.Model.ExceedingPayer as X -import Common.Model.Frequency as X -import Common.Model.Income as X -import Common.Model.IncomeHeader as X -import Common.Model.IncomePage as X -import Common.Model.Init as X -import Common.Model.Password as X -import Common.Model.Payment as X -import Common.Model.PaymentHeader as X -import Common.Model.PaymentPage as X -import Common.Model.SignInForm as X -import Common.Model.Stats as X -import Common.Model.User as X diff --git a/common/src/Common/Model/Category.hs b/common/src/Common/Model/Category.hs deleted file mode 100644 index cc3f795..0000000 --- a/common/src/Common/Model/Category.hs +++ /dev/null @@ -1,24 +0,0 @@ -module Common.Model.Category - ( CategoryId - , Category(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Int (Int64) -import Data.Text (Text) -import Data.Time (UTCTime) -import GHC.Generics (Generic) - -type CategoryId = Int64 - -data Category = Category - { _category_id :: CategoryId - , _category_name :: Text - , _category_color :: Text - , _category_createdAt :: UTCTime - , _category_editedAt :: Maybe UTCTime - , _category_deletedAt :: Maybe UTCTime - } deriving (Eq, Show, Generic) - -instance FromJSON Category -instance ToJSON Category diff --git a/common/src/Common/Model/CategoryPage.hs b/common/src/Common/Model/CategoryPage.hs deleted file mode 100644 index e20f49f..0000000 --- a/common/src/Common/Model/CategoryPage.hs +++ /dev/null @@ -1,18 +0,0 @@ -module Common.Model.CategoryPage - ( CategoryPage(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import GHC.Generics (Generic) - -import Common.Model.Category (Category, CategoryId) - -data CategoryPage = CategoryPage - { _categoryPage_page :: Int - , _categoryPage_categories :: [Category] - , _categoryPage_usedCategories :: [CategoryId] - , _categoryPage_totalCount :: Int - } deriving (Eq, Show, Generic) - -instance FromJSON CategoryPage -instance ToJSON CategoryPage diff --git a/common/src/Common/Model/CreateCategoryForm.hs b/common/src/Common/Model/CreateCategoryForm.hs deleted file mode 100644 index 4668ef4..0000000 --- a/common/src/Common/Model/CreateCategoryForm.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Common.Model.CreateCategoryForm - ( CreateCategoryForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -data CreateCategoryForm = CreateCategoryForm - { _createCategoryForm_name :: Text - , _createCategoryForm_color :: Text - } deriving (Show, Generic) - -instance FromJSON CreateCategoryForm -instance ToJSON CreateCategoryForm diff --git a/common/src/Common/Model/CreateIncomeForm.hs b/common/src/Common/Model/CreateIncomeForm.hs deleted file mode 100644 index e83bf0a..0000000 --- a/common/src/Common/Model/CreateIncomeForm.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Common.Model.CreateIncomeForm - ( CreateIncomeForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -data CreateIncomeForm = CreateIncomeForm - { _createIncomeForm_amount :: Text - , _createIncomeForm_date :: Text - } deriving (Show, Generic) - -instance FromJSON CreateIncomeForm -instance ToJSON CreateIncomeForm diff --git a/common/src/Common/Model/CreatePaymentForm.hs b/common/src/Common/Model/CreatePaymentForm.hs deleted file mode 100644 index 60c5423..0000000 --- a/common/src/Common/Model/CreatePaymentForm.hs +++ /dev/null @@ -1,21 +0,0 @@ -module Common.Model.CreatePaymentForm - ( CreatePaymentForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -import Common.Model.Category (CategoryId) -import Common.Model.Frequency (Frequency) - -data CreatePaymentForm = CreatePaymentForm - { _createPaymentForm_name :: Text - , _createPaymentForm_cost :: Text - , _createPaymentForm_date :: Text - , _createPaymentForm_category :: CategoryId - , _createPaymentForm_frequency :: Frequency - } deriving (Show, Generic) - -instance FromJSON CreatePaymentForm -instance ToJSON CreatePaymentForm diff --git a/common/src/Common/Model/Currency.hs b/common/src/Common/Model/Currency.hs deleted file mode 100644 index 175aeba..0000000 --- a/common/src/Common/Model/Currency.hs +++ /dev/null @@ -1,12 +0,0 @@ -module Common.Model.Currency - ( Currency(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -newtype Currency = Currency Text deriving (Show, Generic) - -instance FromJSON Currency -instance ToJSON Currency diff --git a/common/src/Common/Model/EditCategoryForm.hs b/common/src/Common/Model/EditCategoryForm.hs deleted file mode 100644 index a2ceca0..0000000 --- a/common/src/Common/Model/EditCategoryForm.hs +++ /dev/null @@ -1,18 +0,0 @@ -module Common.Model.EditCategoryForm - ( EditCategoryForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -import Common.Model.Category (CategoryId) - -data EditCategoryForm = EditCategoryForm - { _editCategoryForm_id :: CategoryId - , _editCategoryForm_name :: Text - , _editCategoryForm_color :: Text - } deriving (Show, Generic) - -instance FromJSON EditCategoryForm -instance ToJSON EditCategoryForm diff --git a/common/src/Common/Model/EditIncome.hs b/common/src/Common/Model/EditIncome.hs deleted file mode 100644 index 0e65f12..0000000 --- a/common/src/Common/Model/EditIncome.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Common.Model.EditIncome - ( EditIncome(..) - ) where - -import Data.Aeson (FromJSON) -import Data.Time.Calendar (Day) -import GHC.Generics (Generic) - -import Common.Model.Income (IncomeId) - -data EditIncome = EditIncome - { _editIncome_id :: IncomeId - , _editIncome_date :: Day - , _editIncome_amount :: Int - } deriving (Show, Generic) - -instance FromJSON EditIncome diff --git a/common/src/Common/Model/EditIncomeForm.hs b/common/src/Common/Model/EditIncomeForm.hs deleted file mode 100644 index ff975fc..0000000 --- a/common/src/Common/Model/EditIncomeForm.hs +++ /dev/null @@ -1,18 +0,0 @@ -module Common.Model.EditIncomeForm - ( EditIncomeForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -import Common.Model.Income (IncomeId) - -data EditIncomeForm = EditIncomeForm - { _editIncomeForm_id :: IncomeId - , _editIncomeForm_amount :: Text - , _editIncomeForm_date :: Text - } deriving (Show, Generic) - -instance FromJSON EditIncomeForm -instance ToJSON EditIncomeForm diff --git a/common/src/Common/Model/EditPaymentForm.hs b/common/src/Common/Model/EditPaymentForm.hs deleted file mode 100644 index 168c9ff..0000000 --- a/common/src/Common/Model/EditPaymentForm.hs +++ /dev/null @@ -1,23 +0,0 @@ -module Common.Model.EditPaymentForm - ( EditPaymentForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -import Common.Model.Category (CategoryId) -import Common.Model.Frequency (Frequency) -import Common.Model.Payment (PaymentId) - -data EditPaymentForm = EditPaymentForm - { _editPaymentForm_id :: PaymentId - , _editPaymentForm_name :: Text - , _editPaymentForm_cost :: Text - , _editPaymentForm_date :: Text - , _editPaymentForm_category :: CategoryId - , _editPaymentForm_frequency :: Frequency - } deriving (Show, Generic) - -instance FromJSON EditPaymentForm -instance ToJSON EditPaymentForm diff --git a/common/src/Common/Model/Email.hs b/common/src/Common/Model/Email.hs deleted file mode 100644 index e938f83..0000000 --- a/common/src/Common/Model/Email.hs +++ /dev/null @@ -1,12 +0,0 @@ -module Common.Model.Email - ( Email(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -newtype Email = Email Text deriving (Show, Generic) - -instance FromJSON Email -instance ToJSON Email diff --git a/common/src/Common/Model/ExceedingPayer.hs b/common/src/Common/Model/ExceedingPayer.hs deleted file mode 100644 index b7d3efb..0000000 --- a/common/src/Common/Model/ExceedingPayer.hs +++ /dev/null @@ -1,16 +0,0 @@ -module Common.Model.ExceedingPayer - ( ExceedingPayer(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import GHC.Generics (Generic) - -import Common.Model.User (UserId) - -data ExceedingPayer = ExceedingPayer - { _exceedingPayer_userId :: UserId - , _exceedingPayer_amount :: Int - } deriving (Eq, Show, Generic) - -instance FromJSON ExceedingPayer -instance ToJSON ExceedingPayer diff --git a/common/src/Common/Model/Frequency.hs b/common/src/Common/Model/Frequency.hs deleted file mode 100644 index 48e75ea..0000000 --- a/common/src/Common/Model/Frequency.hs +++ /dev/null @@ -1,14 +0,0 @@ -module Common.Model.Frequency - ( Frequency(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import GHC.Generics (Generic) - -data Frequency = - Punctual - | Monthly - deriving (Eq, Read, Show, Generic, Ord) - -instance FromJSON Frequency -instance ToJSON Frequency diff --git a/common/src/Common/Model/Income.hs b/common/src/Common/Model/Income.hs deleted file mode 100644 index 57d07f1..0000000 --- a/common/src/Common/Model/Income.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Common.Model.Income - ( IncomeId - , Income(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Int (Int64) -import Data.Time (UTCTime) -import Data.Time.Calendar (Day) -import GHC.Generics (Generic) - -import Common.Model.User (UserId) - -type IncomeId = Int64 - -data Income = Income - { _income_id :: IncomeId - , _income_userId :: UserId - , _income_date :: Day - , _income_amount :: Int - , _income_createdAt :: UTCTime - , _income_editedAt :: Maybe UTCTime - , _income_deletedAt :: Maybe UTCTime - } deriving (Eq, Show, Generic) - -instance FromJSON Income -instance ToJSON Income diff --git a/common/src/Common/Model/IncomeHeader.hs b/common/src/Common/Model/IncomeHeader.hs deleted file mode 100644 index 7e712e8..0000000 --- a/common/src/Common/Model/IncomeHeader.hs +++ /dev/null @@ -1,18 +0,0 @@ -module Common.Model.IncomeHeader - ( IncomeHeader(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Map (Map) -import Data.Time.Calendar (Day) -import GHC.Generics (Generic) - -import Common.Model.User (UserId) - -data IncomeHeader = IncomeHeader - { _incomeHeader_since :: Maybe Day - , _incomeHeader_byUser :: Map UserId Int - } deriving (Eq, Show, Generic) - -instance FromJSON IncomeHeader -instance ToJSON IncomeHeader diff --git a/common/src/Common/Model/IncomePage.hs b/common/src/Common/Model/IncomePage.hs deleted file mode 100644 index 977b0ea..0000000 --- a/common/src/Common/Model/IncomePage.hs +++ /dev/null @@ -1,19 +0,0 @@ -module Common.Model.IncomePage - ( IncomePage(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import GHC.Generics (Generic) - -import Common.Model.Income (Income) -import Common.Model.IncomeHeader (IncomeHeader) - -data IncomePage = IncomePage - { _incomePage_page :: Int - , _incomePage_header :: IncomeHeader - , _incomePage_incomes :: [Income] - , _incomePage_totalCount :: Int - } deriving (Eq, Show, Generic) - -instance FromJSON IncomePage -instance ToJSON IncomePage diff --git a/common/src/Common/Model/Init.hs b/common/src/Common/Model/Init.hs deleted file mode 100644 index 5ef1535..0000000 --- a/common/src/Common/Model/Init.hs +++ /dev/null @@ -1,18 +0,0 @@ -module Common.Model.Init - ( Init(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import GHC.Generics (Generic) - -import Common.Model.Currency (Currency) -import Common.Model.User (User, UserId) - -data Init = Init - { _init_users :: [User] - , _init_currentUser :: UserId - , _init_currency :: Currency - } deriving (Show, Generic) - -instance FromJSON Init -instance ToJSON Init diff --git a/common/src/Common/Model/Password.hs b/common/src/Common/Model/Password.hs deleted file mode 100644 index 1b51a47..0000000 --- a/common/src/Common/Model/Password.hs +++ /dev/null @@ -1,12 +0,0 @@ -module Common.Model.Password - ( Password(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -newtype Password = Password Text deriving (Show, Generic) - -instance FromJSON Password -instance ToJSON Password diff --git a/common/src/Common/Model/Payment.hs b/common/src/Common/Model/Payment.hs deleted file mode 100644 index 733a145..0000000 --- a/common/src/Common/Model/Payment.hs +++ /dev/null @@ -1,33 +0,0 @@ -module Common.Model.Payment - ( PaymentId - , Payment(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Int (Int64) -import Data.Text (Text) -import Data.Time (UTCTime) -import Data.Time.Calendar (Day) -import GHC.Generics (Generic) - -import Common.Model.Category (CategoryId) -import Common.Model.Frequency -import Common.Model.User (UserId) - -type PaymentId = Int64 - -data Payment = Payment - { _payment_id :: PaymentId - , _payment_user :: UserId - , _payment_name :: Text - , _payment_cost :: Int - , _payment_date :: Day - , _payment_category :: CategoryId - , _payment_frequency :: Frequency - , _payment_createdAt :: UTCTime - , _payment_editedAt :: Maybe UTCTime - , _payment_deletedAt :: Maybe UTCTime - } deriving (Eq, Show, Generic) - -instance FromJSON Payment -instance ToJSON Payment diff --git a/common/src/Common/Model/PaymentHeader.hs b/common/src/Common/Model/PaymentHeader.hs deleted file mode 100644 index 35f5e1a..0000000 --- a/common/src/Common/Model/PaymentHeader.hs +++ /dev/null @@ -1,18 +0,0 @@ -module Common.Model.PaymentHeader - ( PaymentHeader(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Map (Map) -import GHC.Generics (Generic) - -import Common.Model.ExceedingPayer (ExceedingPayer) -import Common.Model.User (UserId) - -data PaymentHeader = PaymentHeader - { _paymentHeader_exceedingPayers :: [ExceedingPayer] - , _paymentHeader_repartition :: Map UserId Int - } deriving (Eq, Show, Generic) - -instance FromJSON PaymentHeader -instance ToJSON PaymentHeader diff --git a/common/src/Common/Model/PaymentPage.hs b/common/src/Common/Model/PaymentPage.hs deleted file mode 100644 index 88d9715..0000000 --- a/common/src/Common/Model/PaymentPage.hs +++ /dev/null @@ -1,21 +0,0 @@ -module Common.Model.PaymentPage - ( PaymentPage(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import GHC.Generics (Generic) - -import Common.Model.Frequency (Frequency) -import Common.Model.Payment (Payment) -import Common.Model.PaymentHeader (PaymentHeader) - -data PaymentPage = PaymentPage - { _paymentPage_page :: Int - , _paymentPage_frequency :: Frequency - , _paymentPage_header :: PaymentHeader - , _paymentPage_payments :: [Payment] - , _paymentPage_totalCount :: Int - } deriving (Eq, Show, Generic) - -instance FromJSON PaymentPage -instance ToJSON PaymentPage diff --git a/common/src/Common/Model/SignInForm.hs b/common/src/Common/Model/SignInForm.hs deleted file mode 100644 index 7a25935..0000000 --- a/common/src/Common/Model/SignInForm.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Common.Model.SignInForm - ( SignInForm(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Text (Text) -import GHC.Generics (Generic) - -data SignInForm = SignInForm - { _signInForm_email :: Text - , _signInForm_password :: Text - } deriving (Show, Generic) - -instance FromJSON SignInForm -instance ToJSON SignInForm diff --git a/common/src/Common/Model/Stats.hs b/common/src/Common/Model/Stats.hs deleted file mode 100644 index 86e6ab9..0000000 --- a/common/src/Common/Model/Stats.hs +++ /dev/null @@ -1,23 +0,0 @@ -module Common.Model.Stats - ( Stats - , MonthStats(..) - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Map (Map) -import Data.Time.Calendar (Day) -import GHC.Generics (Generic) - -import Common.Model.Category (CategoryId) -import Common.Model.User (UserId) - -type Stats = [MonthStats] - -data MonthStats = MonthStats - { _monthStats_start :: Day - , _monthStats_paymentsByCategory :: Map CategoryId Int - , _monthStats_incomeByUser :: Map UserId Int - } deriving (Eq, Show, Generic) - -instance FromJSON MonthStats -instance ToJSON MonthStats diff --git a/common/src/Common/Model/User.hs b/common/src/Common/Model/User.hs deleted file mode 100644 index a30d104..0000000 --- a/common/src/Common/Model/User.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Common.Model.User - ( UserId - , User(..) - , findUser - ) where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Int (Int64) -import qualified Data.List as L -import Data.Text (Text) -import Data.Time (UTCTime) -import GHC.Generics (Generic) - -type UserId = Int64 - -data User = User - { _user_id :: UserId - , _user_creation :: UTCTime - , _user_email :: Text - , _user_name :: Text - } deriving (Show, Generic) - -instance FromJSON User -instance ToJSON User - -findUser :: UserId -> [User] -> Maybe User -findUser userId users = L.find ((== userId) . _user_id) users diff --git a/common/src/Common/Msg.hs b/common/src/Common/Msg.hs deleted file mode 100644 index 9e4cfe2..0000000 --- a/common/src/Common/Msg.hs +++ /dev/null @@ -1,13 +0,0 @@ -module Common.Msg - ( get - , Key(..) - ) where - -import Data.Text (Text) - -import Common.Message.Key (Key (..)) -import Common.Message.Lang (Lang (..)) -import qualified Common.Message.Translation as Translation - -get :: Key -> Text -get = Translation.get French diff --git a/common/src/Common/Util/Text.hs b/common/src/Common/Util/Text.hs deleted file mode 100644 index 0f9c187..0000000 --- a/common/src/Common/Util/Text.hs +++ /dev/null @@ -1,49 +0,0 @@ -module Common.Util.Text - ( search - , formatSearch - , unaccent - ) where - -import Data.Text (Text) -import qualified Data.Text as T - -search :: Text -> Text -> Bool -search s t = (formatSearch s) `T.isInfixOf` (formatSearch t) - -formatSearch :: Text -> Text -formatSearch = T.toLower . unaccent - -unaccent :: Text -> Text -unaccent = T.map unaccentChar - -unaccentChar :: Char -> Char -unaccentChar c = case c of - 'à' -> 'a' - 'á' -> 'a' - 'â' -> 'a' - 'ã' -> 'a' - 'ä' -> 'a' - 'ç' -> 'c' - 'è' -> 'e' - 'é' -> 'e' - 'ê' -> 'e' - 'ë' -> 'e' - 'ì' -> 'i' - 'í' -> 'i' - 'î' -> 'i' - 'ï' -> 'i' - 'ñ' -> 'n' - 'ò' -> 'o' - 'ó' -> 'o' - 'ô' -> 'o' - 'õ' -> 'o' - 'ö' -> 'o' - 'š' -> 's' - 'ù' -> 'u' - 'ú' -> 'u' - 'û' -> 'u' - 'ü' -> 'u' - 'ý' -> 'y' - 'ÿ' -> 'y' - 'ž' -> 'z' - _ -> c diff --git a/common/src/Common/Util/Time.hs b/common/src/Common/Util/Time.hs deleted file mode 100644 index 6240720..0000000 --- a/common/src/Common/Util/Time.hs +++ /dev/null @@ -1,26 +0,0 @@ -module Common.Util.Time - ( timeToDay - , parseDay - ) where - -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time (UTCTime) -import qualified Data.Time as Time -import Data.Time.Calendar (Day) -import Data.Time.LocalTime -import qualified Text.Read as T - -timeToDay :: UTCTime -> IO Day -timeToDay time = localDay . (flip utcToLocalTime time) <$> getTimeZone time - -parseDay :: Text -> Maybe Day -parseDay str = do - (y, m, d) <- - case T.splitOn "-" str of - [y, m, d] -> Just (y, m, d) - _ -> Nothing - d' <- T.readMaybe . T.unpack $ d - m' <- T.readMaybe . T.unpack $ m - y' <- T.readMaybe . T.unpack $ y - return $ Time.fromGregorian y' m' d' diff --git a/common/src/Common/Util/Validation.hs b/common/src/Common/Util/Validation.hs deleted file mode 100644 index f195d95..0000000 --- a/common/src/Common/Util/Validation.hs +++ /dev/null @@ -1,13 +0,0 @@ -module Common.Util.Validation - ( isSuccess - , isFailure - ) where - -import Data.Validation (Validation (Failure, Success)) - -isSuccess :: forall a b. Validation a b -> Bool -isSuccess (Failure _) = False -isSuccess (Success _) = True - -isFailure :: forall a b. Validation a b -> Bool -isFailure = not . isSuccess diff --git a/common/src/Common/Validation/Atomic.hs b/common/src/Common/Validation/Atomic.hs deleted file mode 100644 index 9c21e14..0000000 --- a/common/src/Common/Validation/Atomic.hs +++ /dev/null @@ -1,61 +0,0 @@ -module Common.Validation.Atomic - ( color - , day - , minLength - , nonEmpty - , nonNullNumber - , number - , password - ) where - -import qualified Data.Char as Char -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Calendar (Day) -import Data.Validation (Validation) -import qualified Data.Validation as V -import qualified Text.Read as T - -import qualified Common.Msg as Msg -import qualified Common.Util.Time as Time - -minLength :: Int -> Text -> Validation Text Text -minLength l = - V.validate - (Msg.get (Msg.Form_MinChars l)) - (\t -> if T.length t >= l then Just t else Nothing) - -nonEmpty :: Text -> Validation Text Text -nonEmpty = - V.validate - (Msg.get Msg.Form_NonEmpty) - (\t -> if (not . T.null $ t) then Just t else Nothing) - -number :: Text -> Validation Text Int -number input = - case (T.readMaybe . T.unpack $ input) of - Just n -> V.Success n - _ -> V.Failure (Msg.get Msg.Form_InvalidInt) - -nonNullNumber :: Int -> Validation Text Int -nonNullNumber = - V.validate - (Msg.get Msg.Form_NonNullNumber) - (\n -> if n /= 0 then Just n else Nothing) - -day :: Text -> Validation Text Day -day str = - case Time.parseDay str of - Just d -> V.Success d - Nothing -> V.Failure $ Msg.get Msg.Form_InvalidDate - -color :: Text -> Validation Text Text -color str = - if T.take 1 str == "#" && T.all Char.isHexDigit (T.drop 1 str) then - V.Success str - - else - V.Failure (Msg.get Msg.Form_InvalidColor) - -password :: Text -> Validation Text Text -password = minLength 8 diff --git a/common/src/Common/Validation/Category.hs b/common/src/Common/Validation/Category.hs deleted file mode 100644 index f9e6ab4..0000000 --- a/common/src/Common/Validation/Category.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Common.Validation.Category - ( name - , color - ) where - -import Data.Text (Text) -import Data.Validation (Validation) - -import qualified Common.Validation.Atomic as Atomic - -name :: Text -> Validation Text Text -name = Atomic.nonEmpty - -color :: Text -> Validation Text Text -color = Atomic.color diff --git a/common/src/Common/Validation/Income.hs b/common/src/Common/Validation/Income.hs deleted file mode 100644 index 7a58bab..0000000 --- a/common/src/Common/Validation/Income.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Common.Validation.Income - ( amount - , date - ) where - -import Data.Text (Text) -import Data.Time.Calendar (Day) -import Data.Validation (Validation) -import qualified Data.Validation as V - -import qualified Common.Validation.Atomic as Atomic - -amount :: Text -> Validation Text Int -amount input = V.bindValidation (Atomic.number input) Atomic.nonNullNumber - -date :: Text -> Validation Text Day -date = Atomic.day diff --git a/common/src/Common/Validation/Payment.hs b/common/src/Common/Validation/Payment.hs deleted file mode 100644 index e3c447a..0000000 --- a/common/src/Common/Validation/Payment.hs +++ /dev/null @@ -1,31 +0,0 @@ -module Common.Validation.Payment - ( name - , cost - , date - , category - ) where - -import Data.Text (Text) -import Data.Time.Calendar (Day) -import Data.Validation (Validation) -import qualified Data.Validation as V - -import Common.Model (CategoryId) -import qualified Common.Msg as Msg -import qualified Common.Validation.Atomic as Atomic - -name :: Text -> Validation Text Text -name = Atomic.nonEmpty - -cost :: Text -> Validation Text Int -cost input = V.bindValidation (Atomic.number input) Atomic.nonNullNumber - -date :: Text -> Validation Text Day -date = Atomic.day - -category :: [CategoryId] -> CategoryId -> Validation Text CategoryId -category cs c = - if elem c cs then - V.Success c - else - V.Failure $ Msg.get Msg.Form_InvalidCategory diff --git a/common/src/Common/Validation/SignIn.hs b/common/src/Common/Validation/SignIn.hs deleted file mode 100644 index ac9cc37..0000000 --- a/common/src/Common/Validation/SignIn.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Common.Validation.SignIn - ( email - , password - ) where - -import Data.Text (Text) -import Data.Validation (Validation) - -import Common.Model.Email (Email (..)) -import Common.Model.Password (Password (..)) -import qualified Common.Validation.Atomic as Atomic - -email :: Text -> Validation Text Email -email = fmap Email . Atomic.minLength 5 - -password :: Text -> Validation Text Password -password = fmap Password . Atomic.minLength 8 diff --git a/common/src/Common/View/Format.hs b/common/src/Common/View/Format.hs deleted file mode 100644 index 5d879fa..0000000 --- a/common/src/Common/View/Format.hs +++ /dev/null @@ -1,78 +0,0 @@ -module Common.View.Format - ( shortDay - , longDay - , price - , number - , monthAndYear - ) where - -import qualified Data.List as L -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Calendar (Day) -import qualified Data.Time.Calendar as Calendar - -import Common.Model (Currency (..)) -import Common.Msg (Key) -import qualified Common.Msg as Msg - -shortDay :: Day -> Text -shortDay date = - Msg.get $ Msg.Date_Short - day - month - (fromIntegral year) - where (year, month, day) = Calendar.toGregorian date - -longDay :: Day -> Text -longDay date = - Msg.get $ Msg.Date_Long - day - (Maybe.fromMaybe "−" . fmap Msg.get . monthToKey $ month) - (fromIntegral year) - where (year, month, day) = Calendar.toGregorian date - -monthAndYear :: Day -> Text -monthAndYear date = - T.intercalate " " - [ Maybe.fromMaybe "" . fmap ((\t -> T.concat [t, " "]) . Msg.get) . monthToKey $ month - , T.pack . show $ year - ] - where (year, month, _) = Calendar.toGregorian date - -monthToKey :: Int -> Maybe Key -monthToKey 1 = Just Msg.Month_January -monthToKey 2 = Just Msg.Month_February -monthToKey 3 = Just Msg.Month_March -monthToKey 4 = Just Msg.Month_April -monthToKey 5 = Just Msg.Month_May -monthToKey 6 = Just Msg.Month_June -monthToKey 7 = Just Msg.Month_July -monthToKey 8 = Just Msg.Month_August -monthToKey 9 = Just Msg.Month_September -monthToKey 10 = Just Msg.Month_October -monthToKey 11 = Just Msg.Month_November -monthToKey 12 = Just Msg.Month_December -monthToKey _ = Nothing - -price :: Currency -> Int -> Text -price (Currency currency) amount = T.concat [ number amount, " ", currency ] - -number :: Int -> Text -number n = - T.pack - . (++) (if n < 0 then "-" else "") - . reverse - . concat - . L.intersperse " " - . group 3 - . reverse - . show - . abs $ n - -group :: Int -> [a] -> [[a]] -group n xs = - if length xs <= n - then [xs] - else (take n xs) : (group n (drop n xs)) diff --git a/config.json b/config.json new file mode 100644 index 0000000..488b2f9 --- /dev/null +++ b/config.json @@ -0,0 +1,4 @@ +{ + "secure_cookies": false, + "mock_mails": true +} diff --git a/default.nix b/default.nix deleted file mode 100644 index fbb3725..0000000 --- a/default.nix +++ /dev/null @@ -1,25 +0,0 @@ -with import ./nix/nixpkgs.nix {}; - -let - reflex-platform = import (pkgs.fetchFromGitHub { - owner = "reflex-frp"; - repo = "reflex-platform"; - - # Mon Jul 29 15:48:55 2019 -0400 - rev = "51e02339704b7502e63bccf10a72fa4dda744b17"; - sha256 = "1mkimidf755968xzbm3z222xgpdvgg6xmmrfppv1hw0rap5w53iw"; - }) {}; -in - reflex-platform.project ({ pkgs, ... }: { - packages = { - validation = ./validation; - common = ./common; - server = ./server; - client = ./client; - }; - - shells = { - ghc = [ "validation" "common" "server" ]; - ghcjs = [ "validation" "common" "client" ]; - }; - }) diff --git a/docs/balance.png b/docs/balance.png new file mode 100644 index 0000000..85bfc82 Binary files /dev/null and b/docs/balance.png differ diff --git a/docs/payments.png b/docs/payments.png new file mode 100644 index 0000000..d811cde Binary files /dev/null and b/docs/payments.png differ diff --git a/docs/statistics.png b/docs/statistics.png new file mode 100644 index 0000000..6284536 Binary files /dev/null and b/docs/statistics.png differ diff --git a/nix/nixpkgs.nix b/nix/nixpkgs.nix deleted file mode 100644 index 9d61277..0000000 --- a/nix/nixpkgs.nix +++ /dev/null @@ -1,6 +0,0 @@ -import ((import {}).fetchFromGitHub { - owner = "NixOS"; - repo = "nixpkgs"; - rev = "19.09"; - sha256 = "1ib96has10v5nr6bzf7v8kw7yzww8zanxgw2qi1ll1sbv6kj6zpd"; -}) diff --git a/nix/tools.nix b/nix/tools.nix deleted file mode 100644 index c14e4e2..0000000 --- a/nix/tools.nix +++ /dev/null @@ -1,15 +0,0 @@ -with import ./nixpkgs.nix {}; - -{ - env = stdenv.mkDerivation { - name = "tools"; - buildInputs = with pkgs; with nodePackages; [ - nodemon - sqlite - cabal-install - tmux - tmuxinator - haskellPackages.stylish-haskell - ]; - }; -} diff --git a/public/css/reset.css b/public/css/reset.css deleted file mode 100644 index 2eecc94..0000000 --- a/public/css/reset.css +++ /dev/null @@ -1,60 +0,0 @@ -html, body, div, span, applet, object, iframe, -h1, h2, h3, h4, h5, h6, p, blockquote, pre, -a, abbr, acronym, address, big, cite, code, -del, dfn, em, img, ins, kbd, q, s, samp, -small, strike, strong, sub, sup, tt, var, -b, u, i, center, -dl, dt, dd, ol, ul, li, -fieldset, form, label, legend, -table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, embed, -figure, figcaption, footer, header, hgroup, -menu, nav, output, ruby, section, summary, -time, mark, audio, video { - margin: 0; - padding: 0; - border: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; -} -/* HTML5 display-role reset for older browsers */ -article, aside, details, figcaption, figure, -footer, header, hgroup, menu, nav, section { - display: block; -} -body { - line-height: 1; -} -ol, ul { - list-style: none; -} -blockquote, q { - quotes: none; -} -blockquote:before, blockquote:after, -q:before, q:after { - content: ''; - content: none; -} -table { - border-collapse: collapse; - border-spacing: 0; -} - -a { - text-decoration: none; - color: inherit; -} - -button { - padding: 0; - border: none; - background-color: transparent; -} -button:hover { cursor: pointer; } -button::-moz-focus-inner { border: 0; } -:focus { outline: none; } - -html { box-sizing: border-box; } -*, *:before, *:after { box-sizing: inherit; } diff --git a/public/images/icon.png b/public/images/icon.png deleted file mode 100644 index 51ca51c..0000000 Binary files a/public/images/icon.png and /dev/null differ diff --git a/public/javascript/.gitkeep b/public/javascript/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/server/LICENSE b/server/LICENSE deleted file mode 100644 index 45644ff..0000000 --- a/server/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/server/Setup.hs b/server/Setup.hs deleted file mode 100644 index 4467109..0000000 --- a/server/Setup.hs +++ /dev/null @@ -1,2 +0,0 @@ -import Distribution.Simple -main = defaultMain diff --git a/server/migrations/1.sql b/server/migrations/1.sql deleted file mode 100644 index d7c300e..0000000 --- a/server/migrations/1.sql +++ /dev/null @@ -1,65 +0,0 @@ -CREATE TABLE IF NOT EXISTS "user" ( - "id" INTEGER PRIMARY KEY, - "creation" TIMESTAMP NOT NULL, - "email" VARCHAR NOT NULL, - "name" VARCHAR NOT NULL, - CONSTRAINT "uniq_user_email" UNIQUE ("email"), - CONSTRAINT "uniq_user_name" UNIQUE ("name") -); - -CREATE TABLE IF NOT EXISTS "job" ( - "id" INTEGER PRIMARY KEY, - "kind" VARCHAR NOT NULL, - "last_execution" TIMESTAMP NULL, - "last_check" TIMESTAMP NULL, - CONSTRAINT "uniq_job_kind" UNIQUE ("kind") -); - -CREATE TABLE IF NOT EXISTS "sign_in"( - "id" INTEGER PRIMARY KEY, - "token" VARCHAR NOT NULL, - "creation" TIMESTAMP NOT NULL, - "email" VARCHAR NOT NULL, - "is_used" BOOLEAN NOT NULL, - CONSTRAINT "uniq_sign_in_token" UNIQUE ("token") -); - -CREATE TABLE IF NOT EXISTS "payment"( - "id" INTEGER PRIMARY KEY, - "user_id" INTEGER NOT NULL REFERENCES "user", - "name" VARCHAR NOT NULL, - "cost" INTEGER NOT NULL, - "date" DATE NOT NULL, - "frequency" VARCHAR NOT NULL, - "created_at" TIMESTAMP NOT NULL, - "edited_at" TIMESTAMP NULL, - "deleted_at" TIMESTAMP NULL -); - -CREATE TABLE IF NOT EXISTS "income"( - "id" INTEGER PRIMARY KEY, - "user_id" INTEGER NOT NULL REFERENCES "user", - "date" DATE NOT NULL, - "amount" INTEGERNOT NULL, - "created_at" TIMESTAMP NOT NULL, - "edited_at" TIMESTAMP NULL, - "deleted_at" TIMESTAMP NULL -); - -CREATE TABLE IF NOT EXISTS "category"( - "id" INTEGER PRIMARY KEY, - "name" VARCHAR NOT NULL, - "color" VARCHAR NOT NULL, - "created_at" TIMESTAMP NOT NULL, - "edited_at" TIMESTAMP NULL, - "deleted_at" TIMESTAMP NULL -); - -CREATE TABLE IF NOT EXISTS "payment_category"( - "id" INTEGER PRIMARY KEY, - "name" VARCHAR NOT NULL, - "category" INTEGER NOT NULL REFERENCES "category", - "created_at" TIMESTAMP NOT NULL, - "edited_at" TIMESTAMP NULL, - CONSTRAINT "uniq_payment_category_name" UNIQUE ("name") -); diff --git a/server/migrations/2.sql b/server/migrations/2.sql deleted file mode 100644 index c1d502f..0000000 --- a/server/migrations/2.sql +++ /dev/null @@ -1,44 +0,0 @@ --- Add payment categories with accents from payment with accents - -INSERT INTO - payment_category (name, category, created_at) -SELECT - DISTINCT lower(payment.name), payment_category.category, datetime('now') -FROM - payment -INNER JOIN - payment_category -ON - replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower(payment.name), 'é', 'e'), 'è', 'e'), 'à', 'a'), 'û', 'u'), 'â', 'a'), 'ê', 'e'), 'â', 'a'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ë', 'e') = payment_category.name -WHERE - payment.name -IN - (SELECT DISTINCT payment.name FROM payment WHERE lower(payment.name) NOT IN (SELECT payment_category.name FROM payment_category) AND payment.deleted_at IS NULL); - --- Remove unused payment categories - -DELETE FROM - payment_category -WHERE - name NOT IN (SELECT DISTINCT lower(name) FROM payment); - --- Add category id to payment table - -PRAGMA foreign_keys = 0; - -ALTER TABLE payment ADD COLUMN "category" INTEGER NOT NULL REFERENCES "category" DEFAULT -1; - -PRAGMA foreign_keys = 1; - -UPDATE - payment -SET - category = (SELECT category FROM payment_category WHERE payment_category.name = LOWER(payment.name)) -WHERE - EXISTS (SELECT category FROM payment_category WHERE payment_category.name = LOWER(payment.name)); - -DELETE FROM payment WHERE category = -1; - --- Remove - -DROP TABLE payment_category; diff --git a/server/migrations/3.sql b/server/migrations/3.sql deleted file mode 100644 index a3d8a13..0000000 --- a/server/migrations/3.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP TABLE sign_in; - -ALTER TABLE user ADD COLUMN "password" TEXT NOT NULL DEFAULT "password"; - -ALTER TABLE user ADD COLUMN "sign_in_token" TEXT NULL; diff --git a/server/server.cabal b/server/server.cabal deleted file mode 100644 index 5427385..0000000 --- a/server/server.cabal +++ /dev/null @@ -1,131 +0,0 @@ -Name: server -Version: 0.0.1 -License: GPL-3 -License-file: LICENSE -Author: Joris Guyonvarch -Maintainer: joris@guyonvarch.me -Category: Web -Build-type: Simple -Cabal-version: >=1.10 - -Executable server - Main-is: Main.hs - Ghc-options: -Wall -Werror - Hs-source-dirs: src - Default-language: Haskell2010 - - Default-extensions: - ExistentialQuantification - LambdaCase - MultiParamTypeClasses - OverloadedStrings - ScopedTypeVariables - - Build-depends: - aeson - , base >= 4.11 && < 5 - , base64-bytestring - , bcrypt - , blaze-builder - , blaze-html - , bytestring - , clay - , clientsession - , common - , config-manager - , containers - , cookie - , filepath - , http-conduit - , http-types - , jsaddle - , mime-mail - , monad-logger - , mtl - , parsec - , process - , random - , resourcet - , scotty - , sqlite-simple - , text - , time - , transformers - , unordered-containers - , uuid - , validation - , wai - , wai-extra - , wai-middleware-static - - other-modules: - Conf - Controller.Category - Controller.Helper - Controller.Income - Controller.Index - Controller.Payment - Controller.Statistics - Controller.User - Cookie - Design.Appearing - Design.Color - Design.Constants - Design.Errors - Design.Form - Design.Global - Design.Helper - Design.Loadable - Design.Media - Design.Modal - Design.Tooltip - Design.View.ConfirmDialog - Design.View.Header - Design.View.NotFound - Design.View.Pages - Design.View.Payment - Design.View.Payment.Form - Design.View.Payment.HeaderForm - Design.View.Payment.HeaderInfos - Design.View.SignIn - Design.View.Stat - Design.View.Table - Design.Views - Job.Daemon - Job.Frequency - Job.Kind - Job.Model - Job.MonthlyPayment - Job.WeeklyReport - LoginSession - Model.CreateCategory - Model.CreateIncome - Model.CreatePayment - Model.EditCategory - Model.EditIncome - Model.EditPayment - Model.HashedPassword - Model.IncomeResource - Model.Mail - Model.PaymentResource - Model.Query - Model.SignIn - Model.UUID - Payer - Persistence.Category - Persistence.Frequency - Persistence.Income - Persistence.Payment - Persistence.User - Persistence.Util - Resource - Secure - SendMail - Statistics - Util.Time - Validation.Category - Validation.Income - Validation.Payment - Validation.SignIn - View.Mail.WeeklyReport - View.Page diff --git a/server/src/Conf.hs b/server/src/Conf.hs deleted file mode 100644 index ca19c8d..0000000 --- a/server/src/Conf.hs +++ /dev/null @@ -1,39 +0,0 @@ -module Conf - ( get - , Conf(..) - ) where - -import qualified Data.ConfigManager as Conf -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Clock (NominalDiffTime) - -import Common.Model (Currency (..)) - -data Conf = Conf - { hostname :: Text - , port :: Int - , signInExpiration :: NominalDiffTime - , currency :: Currency - , noReplyMail :: Text - , https :: Bool - , devMode :: Bool - } deriving Show - -get :: FilePath -> IO Conf -get path = do - conf <- - (flip fmap) (Conf.readConfig path) (\configOrError -> do - conf <- configOrError - Conf <$> - Conf.lookup "hostname" conf <*> - Conf.lookup "port" conf <*> - Conf.lookup "signInExpiration" conf <*> - fmap Currency (Conf.lookup "currency" conf) <*> - Conf.lookup "noReplyMail" conf <*> - Conf.lookup "https" conf <*> - Conf.lookup "devMode" conf - ) - case conf of - Left msg -> error (T.unpack msg) - Right c -> return c diff --git a/server/src/Controller/Category.hs b/server/src/Controller/Category.hs deleted file mode 100644 index 371ba78..0000000 --- a/server/src/Controller/Category.hs +++ /dev/null @@ -1,88 +0,0 @@ -module Controller.Category - ( listAll - , list - , create - , edit - , delete - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.Text.Lazy as TL -import Data.Validation (Validation (..)) -import Network.HTTP.Types.Status (badRequest400, ok200) -import Web.Scotty hiding (delete) - -import Common.Model (CategoryId, CategoryPage (..), - CreateCategoryForm (..), - EditCategoryForm (..)) -import qualified Common.Msg as Msg - -import qualified Controller.Helper as ControllerHelper -import Model.CreateCategory (CreateCategory (..)) -import Model.EditCategory (EditCategory (..)) -import qualified Model.Query as Query -import qualified Persistence.Category as CategoryPersistence -import qualified Persistence.Payment as PaymentPersistence -import qualified Secure -import qualified Validation.Category as CategoryValidation - -listAll :: ActionM () -listAll = - Secure.loggedAction (\_ -> - (liftIO . Query.run $ CategoryPersistence.listAll) >>= json - ) - -list :: Int -> Int -> ActionM () -list page perPage = - Secure.loggedAction (\_ -> - (liftIO . Query.run $ do - categories <- CategoryPersistence.list page perPage - usedCategories <- PaymentPersistence.usedCategories - count <- CategoryPersistence.count - return $ CategoryPage page categories usedCategories count - ) >>= json - ) - -create :: CreateCategoryForm -> ActionM () -create form = - Secure.loggedAction (\_ -> - (liftIO . Query.run $ do - case CategoryValidation.createCategory form of - Success (CreateCategory name color) -> do - Right <$> (CategoryPersistence.create name color) - - Failure validationError -> - return $ Left validationError - ) >>= ControllerHelper.okOrBadRequest - ) - -edit :: EditCategoryForm -> ActionM () -edit form = - Secure.loggedAction (\_ -> - (liftIO . Query.run $ do - case CategoryValidation.editCategory form of - Success (EditCategory categoryId name color) -> - do - isSuccess <- CategoryPersistence.edit categoryId name color - return $ if isSuccess then - Right () - else - Left $ Msg.get Msg.Error_CategoryEdit - - Failure validationError -> - return $ Left validationError - ) >>= ControllerHelper.okOrBadRequest - ) - -delete :: CategoryId -> ActionM () -delete categoryId = - Secure.loggedAction (\_ -> do - deleted <- liftIO . Query.run $ do - CategoryPersistence.delete categoryId - if deleted - then - status ok200 - else do - status badRequest400 - text . TL.fromStrict $ Msg.get Msg.Category_NotDeleted - ) diff --git a/server/src/Controller/Helper.hs b/server/src/Controller/Helper.hs deleted file mode 100644 index dc9cbc4..0000000 --- a/server/src/Controller/Helper.hs +++ /dev/null @@ -1,16 +0,0 @@ -module Controller.Helper - ( okOrBadRequest - ) where - -import Data.Text (Text) -import qualified Data.Text.Lazy as LT -import qualified Network.HTTP.Types.Status as Status -import Web.Scotty (ActionM) -import qualified Web.Scotty as S - -okOrBadRequest :: Either Text () -> ActionM () -okOrBadRequest (Left message) = do - S.status Status.badRequest400 - S.text (LT.fromStrict message) -okOrBadRequest (Right ()) = - S.status Status.ok200 diff --git a/server/src/Controller/Income.hs b/server/src/Controller/Income.hs deleted file mode 100644 index 96ccbbc..0000000 --- a/server/src/Controller/Income.hs +++ /dev/null @@ -1,90 +0,0 @@ -module Controller.Income - ( list - , create - , edit - , delete - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.Map as M -import qualified Data.Time.Clock as Clock -import Data.Validation (Validation (..)) -import qualified Network.HTTP.Types.Status as Status -import Web.Scotty hiding (delete) - -import Common.Model (CreateIncomeForm (..), - EditIncomeForm (..), - IncomeHeader (..), IncomeId, - IncomePage (..), User (..)) -import qualified Common.Msg as Msg - -import qualified Controller.Helper as ControllerHelper -import Model.CreateIncome (CreateIncome (..)) -import Model.EditIncome (EditIncome (..)) -import qualified Model.Query as Query -import qualified Persistence.Income as IncomePersistence -import qualified Persistence.Payment as PaymentPersistence -import qualified Persistence.User as UserPersistence -import qualified Secure -import qualified Validation.Income as IncomeValidation - -list :: Int -> Int -> ActionM () -list page perPage = - Secure.loggedAction (\_ -> do - currentTime <- liftIO Clock.getCurrentTime - (liftIO . Query.run $ do - count <- IncomePersistence.count - - users <- UserPersistence.list - let userIds = _user_id <$> users - - paymentRange <- PaymentPersistence.getRange - incomeDefinedForAll <- IncomePersistence.definedForAll userIds - let since = max <$> (fst <$> paymentRange) <*> incomeDefinedForAll - - cumulativeIncome <- - case since of - Just s -> IncomePersistence.getCumulativeIncome s (Clock.utctDay currentTime) - Nothing -> return M.empty - - incomes <- IncomePersistence.list page perPage - return $ IncomePage page (IncomeHeader since cumulativeIncome) incomes count) >>= json - ) - -create :: CreateIncomeForm -> ActionM () -create form = - Secure.loggedAction (\user -> - (liftIO . Query.run $ do - case IncomeValidation.createIncome form of - Success (CreateIncome amount date) -> do - Right <$> (IncomePersistence.create (_user_id user) date amount) - - Failure validationError -> - return $ Left validationError - ) >>= ControllerHelper.okOrBadRequest - ) - -edit :: EditIncomeForm -> ActionM () -edit form = - Secure.loggedAction (\user -> - (liftIO . Query.run $ do - case IncomeValidation.editIncome form of - Success (EditIncome incomeId amount date) -> - do - isSuccess <- IncomePersistence.edit (_user_id user) incomeId date amount - return $ if isSuccess then - Right () - else - Left $ Msg.get Msg.Error_IncomeEdit - - Failure validationError -> - return $ Left validationError - ) >>= ControllerHelper.okOrBadRequest - ) - -delete :: IncomeId -> ActionM () -delete incomeId = - Secure.loggedAction (\user -> do - _ <- liftIO . Query.run $ IncomePersistence.delete (_user_id user) incomeId - status Status.ok200 - ) diff --git a/server/src/Controller/Index.hs b/server/src/Controller/Index.hs deleted file mode 100644 index 4f4ae77..0000000 --- a/server/src/Controller/Index.hs +++ /dev/null @@ -1,76 +0,0 @@ -module Controller.Index - ( get - , signIn - , signOut - ) where - -import Control.Monad.IO.Class (liftIO) -import Data.Text (Text) -import qualified Data.Text.Lazy as TL -import Data.Validation (Validation (..)) -import qualified Network.HTTP.Types.Status as Status -import Prelude hiding (error, init) -import Web.Scotty (ActionM) -import qualified Web.Scotty as S - -import Common.Model (Init (..), SignInForm (..), - User (..)) -import qualified Common.Msg as Msg - -import Conf (Conf (..)) -import qualified LoginSession -import Model.Query (Query) -import qualified Model.Query as Query -import Model.SignIn (SignIn (..)) -import qualified Persistence.User as UserPersistence -import qualified Validation.SignIn as SignInValidation -import View.Page (page) - -get :: Conf -> ActionM () -get conf = do - init <- do - mbToken <- LoginSession.get - case mbToken of - Nothing -> - return Nothing - Just token -> do - liftIO . Query.run $ getInit conf token - S.html $ page init - -signIn :: Conf -> SignInForm -> ActionM () -signIn conf form = - case SignInValidation.signIn form of - Failure _ -> - textKey Status.badRequest400 Msg.SignIn_InvalidCredentials - Success (SignIn email password) -> do - result <- liftIO . Query.run $ do - isPasswordValid <- UserPersistence.checkPassword email password - if isPasswordValid then - do - signInToken <- UserPersistence.createSignInToken email - init <- getInit conf signInToken - return $ Just (signInToken, init) - else - return Nothing - case result of - Just (signInToken, init) -> do - LoginSession.put conf signInToken - S.json init - - Nothing -> - textKey Status.badRequest400 Msg.SignIn_InvalidCredentials - where textKey st key = S.status st >> (S.text . TL.fromStrict $ Msg.get key) - -getInit :: Conf -> Text -> Query (Maybe Init) -getInit conf signInToken = do - user <- UserPersistence.get signInToken - case user of - Just u -> - do - users <- UserPersistence.list - return . Just $ Init users (_user_id u) (Conf.currency conf) - Nothing -> - return Nothing - -signOut :: Conf -> ActionM () -signOut conf = LoginSession.delete conf >> S.status Status.ok200 diff --git a/server/src/Controller/Payment.hs b/server/src/Controller/Payment.hs deleted file mode 100644 index 4fb4d54..0000000 --- a/server/src/Controller/Payment.hs +++ /dev/null @@ -1,118 +0,0 @@ -module Controller.Payment - ( list - , create - , edit - , delete - , searchCategory - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.Map as M -import Data.Text (Text) -import qualified Data.Time.Clock as Clock -import qualified Data.Time.Calendar as Calendar -import Data.Validation (Validation (Failure, Success)) -import Web.Scotty (ActionM) -import qualified Web.Scotty as S - -import Common.Model (Category (..), CreatePaymentForm (..), - EditPaymentForm (..), Frequency, - PaymentHeader (..), PaymentId, - PaymentPage (..), User (..)) -import qualified Common.Msg as Msg - -import qualified Controller.Helper as ControllerHelper -import Model.CreatePayment (CreatePayment (..)) -import Model.EditPayment (EditPayment (..)) -import qualified Model.Query as Query -import qualified Payer as Payer -import qualified Persistence.Category as CategoryPersistence -import qualified Persistence.Income as IncomePersistence -import qualified Persistence.Payment as PaymentPersistence -import qualified Persistence.User as UserPersistence -import qualified Secure -import qualified Validation.Payment as PaymentValidation - -list :: Frequency -> Int -> Int -> Text -> ActionM () -list frequency page perPage search = - Secure.loggedAction (\_ -> do - currentUtctDay <- liftIO $ Clock.utctDay <$> Clock.getCurrentTime - (liftIO . Query.run $ do - count <- PaymentPersistence.count frequency search - payments <- PaymentPersistence.listActivePage frequency page perPage search - - users <- UserPersistence.list - - paymentRange <- PaymentPersistence.getRange - incomeDefinedForAll <- IncomePersistence.definedForAll (_user_id <$> users) - - cumulativeIncome <- - case (incomeDefinedForAll, paymentRange) of - (Just incomeStart, Just (paymentStart, _)) -> - IncomePersistence.getCumulativeIncome (max incomeStart paymentStart) currentUtctDay - - _ -> - return M.empty - - searchRepartition <- - case paymentRange of - Just (from, to) -> - PaymentPersistence.repartition frequency search from (Calendar.addDays 1 to) - Nothing -> - return M.empty - - (preIncomeRepartition, postIncomeRepartition) <- - PaymentPersistence.getPreAndPostPaymentRepartition paymentRange users - - let exceedingPayers = Payer.getExceedingPayers users cumulativeIncome preIncomeRepartition postIncomeRepartition - - header = PaymentHeader - { _paymentHeader_exceedingPayers = exceedingPayers - , _paymentHeader_repartition = searchRepartition - } - - return $ PaymentPage page frequency header payments count) >>= S.json - ) - -create :: CreatePaymentForm -> ActionM () -create form = - Secure.loggedAction (\user -> - (liftIO . Query.run $ do - cs <- map _category_id <$> CategoryPersistence.listAll - case PaymentValidation.createPayment cs form of - Success (CreatePayment name cost date category frequency) -> - Right <$> PaymentPersistence.create (_user_id user) name cost date category frequency - Failure validationError -> - return $ Left validationError - ) >>= ControllerHelper.okOrBadRequest - ) - -edit :: EditPaymentForm -> ActionM () -edit form = - Secure.loggedAction (\user -> - (liftIO . Query.run $ do - cs <- map _category_id <$> CategoryPersistence.listAll - case PaymentValidation.editPayment cs form of - Success (EditPayment paymentId name cost date category frequency) -> do - isSuccess <- PaymentPersistence.edit (_user_id user) paymentId name cost date category frequency - return $ if isSuccess then - Right () - else - Left $ Msg.get Msg.Error_PaymentEdit - Failure validationError -> - return $ Left validationError - ) >>= ControllerHelper.okOrBadRequest - ) - -delete :: PaymentId -> ActionM () -delete paymentId = - Secure.loggedAction (\user -> - liftIO . Query.run $ PaymentPersistence.delete (_user_id user) paymentId - ) - -searchCategory :: Text -> ActionM () -searchCategory paymentName = - Secure.loggedAction (\_ -> do - (liftIO $ Query.run (PaymentPersistence.searchCategory paymentName)) - >>= S.json - ) diff --git a/server/src/Controller/Statistics.hs b/server/src/Controller/Statistics.hs deleted file mode 100644 index 500c93c..0000000 --- a/server/src/Controller/Statistics.hs +++ /dev/null @@ -1,21 +0,0 @@ -module Controller.Statistics - ( paymentsAndIncomes - ) where - -import Control.Monad.IO.Class (liftIO) -import Web.Scotty (ActionM) -import qualified Web.Scotty as S - -import qualified Model.Query as Query -import qualified Persistence.Income as IncomePersistence -import qualified Persistence.Payment as PaymentPersistence -import qualified Secure -import qualified Statistics - -paymentsAndIncomes :: ActionM () -paymentsAndIncomes = - Secure.loggedAction (\_ -> do - payments <- liftIO $ Query.run PaymentPersistence.listAllPunctual - incomes <- liftIO $ Query.run IncomePersistence.listAll - S.json (Statistics.paymentsAndIncomes payments incomes) - ) diff --git a/server/src/Controller/User.hs b/server/src/Controller/User.hs deleted file mode 100644 index a7bb136..0000000 --- a/server/src/Controller/User.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Controller.User - ( list - ) where - -import Control.Monad.IO.Class (liftIO) -import Web.Scotty (ActionM) -import qualified Web.Scotty as S - -import qualified Model.Query as Query -import qualified Persistence.User as UserPersistence -import qualified Secure - -list :: ActionM () -list = - Secure.loggedAction (\_ -> - (liftIO . Query.run $ UserPersistence.list) >>= S.json - ) diff --git a/server/src/Cookie.hs b/server/src/Cookie.hs deleted file mode 100644 index 00d73f2..0000000 --- a/server/src/Cookie.hs +++ /dev/null @@ -1,55 +0,0 @@ -module Cookie - ( makeSimpleCookie - , setCookie - , setSimpleCookie - , getCookie - , getCookies - , deleteCookie - ) where - -import Control.Monad (liftM) - -import qualified Data.Text as TS -import qualified Data.Text.Encoding as TS -import qualified Data.Text.Lazy.Encoding as TL - -import Conf (Conf) -import qualified Conf - -import qualified Data.Map as Map - -import qualified Data.ByteString.Lazy as BSL - -import Data.Time.Clock.POSIX (posixSecondsToUTCTime) - -import Blaze.ByteString.Builder (toLazyByteString) - -import Web.Cookie -import Web.Scotty.Trans - -makeSimpleCookie :: Conf -> TS.Text -> TS.Text -> SetCookie -makeSimpleCookie conf name value = - def - { setCookieName = TS.encodeUtf8 name - , setCookieValue = TS.encodeUtf8 value - , setCookiePath = Just $ TS.encodeUtf8 "/" - , setCookieSecure = Conf.https conf - , setCookieHttpOnly = True - } - -setCookie :: (Monad m) => SetCookie -> ActionT e m () -setCookie name = addHeader "Set-Cookie" (TL.decodeUtf8 . toLazyByteString $ renderSetCookie name) - -setSimpleCookie :: (Monad m) => Conf -> TS.Text -> TS.Text -> ActionT e m () -setSimpleCookie conf name value = setCookie $ makeSimpleCookie conf name value - -getCookie :: (Monad m, ScottyError e) => TS.Text -> ActionT e m (Maybe TS.Text) -getCookie name = liftM (Map.lookup name) getCookies - -getCookies :: (Monad m, ScottyError e) => ActionT e m (Map.Map TS.Text TS.Text) -getCookies = - liftM (Map.fromList . maybe [] parse) $ header "Cookie" - where parse = parseCookiesText . BSL.toStrict . TL.encodeUtf8 - -deleteCookie :: (Monad m) => Conf -> TS.Text -> ActionT e m () -deleteCookie conf name = setCookie $ (makeSimpleCookie conf name "") { setCookieExpires = Just $ posixSecondsToUTCTime 0 } diff --git a/server/src/Design/Appearing.hs b/server/src/Design/Appearing.hs deleted file mode 100644 index 79b94b3..0000000 --- a/server/src/Design/Appearing.hs +++ /dev/null @@ -1,25 +0,0 @@ -module Design.Appearing - ( design - ) where - -import Clay - -design :: Css -design = do - - appearKeyframe - - ".g-Appearing" ? do - appearAnimation - -appearAnimation :: Css -appearAnimation = do - animationName "appear" - animationDuration (sec 0.2) - animationTimingFunction easeIn - -appearKeyframe :: Css -appearKeyframe = keyframes - "appear" - [ (0, "opacity" -: "0") - ] diff --git a/server/src/Design/Color.hs b/server/src/Design/Color.hs deleted file mode 100644 index e7f5aec..0000000 --- a/server/src/Design/Color.hs +++ /dev/null @@ -1,40 +0,0 @@ -module Design.Color where - -import Clay -import qualified Clay.Color as C -import Data.Text (Text) - --- http://chir.ag/projects/name-that-color/#969696 - -white :: C.Color -white = C.white - -black :: C.Color -black = C.black - -chestnutRose :: C.Color -chestnutRose = C.rgb 207 92 86 - -unknown :: C.Color -unknown = C.rgb 86 92 207 - -mossGreen :: C.Color -mossGreen = C.rgb 159 210 165 - -gothic :: C.Color -gothic = C.rgb 108 162 164 - -negroni :: C.Color -negroni = C.rgb 255 223 196 - -wildSand :: C.Color -wildSand = C.rgb 245 245 245 - -silver :: C.Color -silver = C.rgb 200 200 200 - -dustyGray :: C.Color -dustyGray = C.rgb 150 150 150 - -toString :: C.Color -> Text -toString = plain . unValue . value diff --git a/server/src/Design/Constants.hs b/server/src/Design/Constants.hs deleted file mode 100644 index a3123d9..0000000 --- a/server/src/Design/Constants.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Design.Constants where - -import Clay - -iconFontSize :: Size LengthUnit -iconFontSize = px 32 - -radius :: Size LengthUnit -radius = px 3 - -blockPadding :: Size LengthUnit -blockPadding = px 15 - -blockPercentWidth :: Double -blockPercentWidth = 90 - -blockPercentMargin :: Double -blockPercentMargin = (100 - blockPercentWidth) / 2 - -inputHeight :: Double -inputHeight = 40 - -focusLighten :: Color -> Color -focusLighten baseColor = baseColor +. 20 - -focusDarken :: Color -> Color -focusDarken baseColor = baseColor -. 20 diff --git a/server/src/Design/Errors.hs b/server/src/Design/Errors.hs deleted file mode 100644 index 9f435eb..0000000 --- a/server/src/Design/Errors.hs +++ /dev/null @@ -1,53 +0,0 @@ -module Design.Errors - ( design - ) where - -import Clay - -import Design.Color as Color - -design :: Css -design = do - position fixed - top (px 20) - left (pct 50) - "transform" -: "translateX(-50%)" - margin (px 0) (px 0) (px 0) (px 0) - disapearKeyframes - - ".error" ? do - disapearAnimation - let errorColor = Color.chestnutRose -. 15 - color errorColor - border solid (px 2) errorColor - backgroundColor Color.white - borderRadius (px 5) (px 5) (px 5) (px 5) - padding (px 5) (px 5) (px 5) (px 5) - - before & display none - -disapearAnimation :: Css -disapearAnimation = do - animationName "disapear" - animationDelay (sec 5) - animationDuration (sec 1) - animationFillMode forwards - -disapearKeyframes :: Css -disapearKeyframes = keyframes - "disapear" - [ ( 10 - , do - opacity 0 - height (px 40) - lineHeight (px 40) - marginBottom (px 10) - ) - , ( 100 - , do - opacity 0 - height (px 0) - lineHeight (px 0) - marginBottom (px 0) - ) - ] diff --git a/server/src/Design/Form.hs b/server/src/Design/Form.hs deleted file mode 100644 index 5713bfe..0000000 --- a/server/src/Design/Form.hs +++ /dev/null @@ -1,101 +0,0 @@ -module Design.Form - ( design - ) where - -import Data.Monoid ((<>)) - -import Clay - -import Design.Color as Color - -design :: Css -design = do - - let inputHeight = 30 - let inputTop = 22 - let inputPaddingBottom = 3 - - ".textInput" ? do - position relative - marginBottom (em 2) - paddingTop (px inputTop) - marginTop (px (-10)) - - input ? do - width (pct 100) - position relative - backgroundColor transparent - paddingBottom (px inputPaddingBottom) - paddingRight (px 14) -- Space for the delete icon - borderStyle none - borderBottom solid (px 1) Color.dustyGray - marginBottom (px 5) - height (px inputHeight) - lineHeight (px inputHeight) - focus & do - borderWidth (px 2) - paddingBottom (px $ inputPaddingBottom - 1) - - ".label" ? do - zIndex (-1) - color Color.silver - lineHeight (px inputHeight) - position absolute - top (px inputTop) - left (px 0) - transition "all" (sec 0.2) easeInOut (sec 0) - - button ? do - position absolute - right (px 0) - top (px 27) - svg ? "path" ? - ("fill" -: Color.toString Color.silver) - hover & svg ? "path" ? - ("fill" -: Color.toString (Color.silver -. 25)) - - (input # ".filled" |+ ".label") <> (input # focus |+ ".label") ? do - top (px 0) - fontSize (pct 80) - - ".error" & do - input ? do - borderBottomColor Color.chestnutRose - - ".errorMessage" ? do - position absolute - color Color.chestnutRose - fontSize (pct 80) - - ".colorInput" ? do - display flex - alignItems center - marginBottom (em 1.5) - - input ? do - borderColor transparent - backgroundColor transparent - - ".selectInput" ? do - - ".label" ? do - color Color.silver - display block - marginBottom (px 10) - fontSize (pct 80) - - select ? do - width (pct 100) - backgroundColor Color.white - border solid (px 1) Color.silver - sym borderRadius (px 3) - sym2 padding (px 5) (px 8) - option ? sym2 padding (px 5) (px 8) - focus & backgroundColor Color.wildSand - - ".error" & do - select ? borderColor Color.chestnutRose - ".errorMessage" ? do - color Color.chestnutRose - fontSize (pct 80) - marginTop (em 0.5) diff --git a/server/src/Design/Global.hs b/server/src/Design/Global.hs deleted file mode 100644 index c67db7c..0000000 --- a/server/src/Design/Global.hs +++ /dev/null @@ -1,165 +0,0 @@ -module Design.Global - ( globalDesign - ) where - -import Clay -import Clay.Color as C -import Data.Text.Lazy (Text) - -import qualified Design.Appearing as Appearing -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Errors as Errors -import qualified Design.Form as Form -import qualified Design.Helper as Helper -import qualified Design.Loadable as Loadable -import qualified Design.Media as Media -import qualified Design.Modal as Modal -import qualified Design.Tooltip as Tooltip -import qualified Design.Views as Views - -globalDesign :: Text -globalDesign = renderWith compact [] global - -global :: Css -global = do - ".errors" ? Errors.design - Appearing.design - Modal.design - ".tooltip" ? Tooltip.design - Views.design - Form.design - Loadable.design - - spinKeyframes - appearKeyframe - - html ? do - height (pct 100) - - "g-Body--Modal" ? - overflowY hidden - - body ? do - position relative - minWidth (px 320) - height (pct 100) - fontFamily ["Cantarell"] [sansSerif] - Media.tablet $ do - fontSize (px 15) - button ? fontSize (px 15) - input ? fontSize (px 15) - Media.mobile $ do - fontSize (px 14) - button ? fontSize (px 14) - input ? fontSize (px 14) - - ".app" ? do - appearAnimation - display flex - height (pct 100) - flexDirection column - - -- "main" ? - -- appearAnimation - - ".pageSpinner" ? do - display flex - alignItems center - justifyContent center - flexGrow 1 - - ".spinner" ? do - display flex - alignItems center - justifyContent center - width (pct 100) - height (pct 100) - paddingBottom (pct 10) - - before & do - display block - content (stringContent "") - width (px 50) - height (px 50) - border solid (px 3) (C.setA 0.3 Color.chestnutRose) - sym borderRadius (pct 50) - borderTopColor Color.chestnutRose - spinKeyframes - spinAnimation - - a ? cursor pointer - - input ? fontSize inherit - - h1 ? do - color Color.chestnutRose - lineHeight (em 1.3) - - Media.desktop $ fontSize (px 24) - Media.tablet $ fontSize (px 22) - Media.mobile $ fontSize (px 20) - - ul ? do - "margin-top" -: "1vh" - "margin-bottom" -: "3vh" - "margin-left" -: "1vh" - li Color -> Size a -> (Color -> Color) -> Css -button backgroundCol textCol h focusOp = do - display flex - alignItems center - justifyContent center - backgroundColor backgroundCol - padding (px 0) (px 10) (px 0) (px 10) - color textCol - borderRadius radius radius radius radius - verticalAlign middle - cursor pointer - lineHeight h - height h - textAlign (alignSide sideCenter) - hover & backgroundColor (focusOp backgroundCol) - focus & backgroundColor (focusOp backgroundCol) - -centeredWithMargin :: Css -centeredWithMargin = do - width (pct blockPercentWidth) - marginLeft auto - marginRight auto - -verticalCentering :: Css -verticalCentering = do - position absolute - top (pct 50) - "transform" -: "translateY(-50%)" diff --git a/server/src/Design/Loadable.hs b/server/src/Design/Loadable.hs deleted file mode 100644 index 6b13f2d..0000000 --- a/server/src/Design/Loadable.hs +++ /dev/null @@ -1,29 +0,0 @@ -module Design.Loadable - ( design - ) where - -import Clay - -design :: Css -design = do - ".g-Loadable" ? do - position relative - width (pct 100) - height (pct 100) - - ".g-Loadable__Spinner" ? do - position absolute - top (px 0) - left (px 0) - width (pct 100) - height (pct 100) - display none - - ".g-Loadable__Spinner--Loading" ? do - display block - - ".g-Loadable__Content" ? - transition "opacity" (sec 0.4) ease (sec 0) - - ".g-Loadable__Content--Loading" ? - opacity 0.5 diff --git a/server/src/Design/Media.hs b/server/src/Design/Media.hs deleted file mode 100644 index 19a3b8c..0000000 --- a/server/src/Design/Media.hs +++ /dev/null @@ -1,36 +0,0 @@ -module Design.Media - ( mobile - , mobileTablet - , tablet - , tabletDesktop - , desktop - ) where - -import Clay hiding (query) -import qualified Clay -import qualified Clay.Media as Media -import Clay.Stylesheet (Feature) - -mobile :: Css -> Css -mobile = query [Media.maxWidth mobileTabletLimit] - -mobileTablet :: Css -> Css -mobileTablet = query [Media.maxWidth tabletDesktopLimit] - -tablet :: Css -> Css -tablet = query [Media.minWidth mobileTabletLimit, Media.maxWidth tabletDesktopLimit] - -tabletDesktop :: Css -> Css -tabletDesktop = query [Media.minWidth mobileTabletLimit] - -desktop :: Css -> Css -desktop = query [Media.minWidth tabletDesktopLimit] - -query :: [Feature] -> Css -> Css -query = Clay.query Media.screen - -mobileTabletLimit :: Size LengthUnit -mobileTabletLimit = (px 520) - -tabletDesktopLimit :: Size LengthUnit -tabletDesktopLimit = (px 950) diff --git a/server/src/Design/Modal.hs b/server/src/Design/Modal.hs deleted file mode 100644 index 1195e10..0000000 --- a/server/src/Design/Modal.hs +++ /dev/null @@ -1,69 +0,0 @@ -module Design.Modal - ( design - ) where - -import Clay -import Data.Monoid ((<>)) - -import qualified Design.View.Payment.Form as Form - -design :: Css -design = do - - appearKeyframe - - ".g-Modal" ? do - display none - appearAnimation - transition "all" (sec 0.2) ease (sec 0) - opacity 0 - - ".g-Modal--Show" & do - display block - opacity 1 - - ".g-Modal--Hiding" & do - display block - - ".g-Modal__Curtain" ? do - position fixed - top (px 0) - left (px 0) - width (pct 100) - height (pct 100) - backgroundColor (rgba 0 0 0 0.6) - zIndex 1 - - ".g-Modal__Content" ? do - minWidth (px 300) - position fixed - top (pct 25) - left (pct 50) - "transform" -: "translate(-50%, -25%)" - zIndex 1 - backgroundColor white - sym borderRadius (px 5) - boxShadow . pure . bsColor (rgba 0 0 0 0.5) $ shadowWithBlur (px 0) (px 0) (px 15) - - ".form" ? Form.design - - ".paymentModal" & do - ".radioGroup" ? ".title" ? display none - ".selectInput" ? do - select ? width (pct 100) - marginBottom (em 1) - - ".deletePaymentModal" <> ".deleteIncomeModal" ? do - h1 ? marginBottom (em 1.5) - -appearAnimation :: Css -appearAnimation = do - animationName "appear" - animationDuration (sec 0.15) - animationTimingFunction easeIn - -appearKeyframe :: Css -appearKeyframe = keyframes - "appear" - [ (0, "opacity" -: "0") - ] diff --git a/server/src/Design/Tooltip.hs b/server/src/Design/Tooltip.hs deleted file mode 100644 index eef804e..0000000 --- a/server/src/Design/Tooltip.hs +++ /dev/null @@ -1,14 +0,0 @@ -module Design.Tooltip - ( design - ) where - -import Clay - -import Design.Color as Color - -design :: Css -design = do - backgroundColor Color.mossGreen - borderRadius (px 5) (px 5) (px 5) (px 5) - padding (px 5) (px 5) (px 5) (px 5) - color Color.white diff --git a/server/src/Design/View/ConfirmDialog.hs b/server/src/Design/View/ConfirmDialog.hs deleted file mode 100644 index 410d4d8..0000000 --- a/server/src/Design/View/ConfirmDialog.hs +++ /dev/null @@ -1,36 +0,0 @@ -module Design.View.ConfirmDialog - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper - -design :: Css -design = do - ".confirm" ? do - ".confirmHeader" ? do - backgroundColor Color.chestnutRose - fontSize (px 18) - color Color.white - sym padding (px 20) - textAlign (alignSide sideCenter) - borderRadius (px 5) (px 5) (px 0) (px 0) - - ".confirmContent" ? do - sym padding (px 20) - - ".buttons" ? do - display flex - justifyContent spaceAround - marginTop (em 1.5) - - ".confirm" ? - Helper.button Color.chestnutRose Color.white (px Constants.inputHeight) Constants.focusLighten - ".undo" ? - Helper.button Color.silver Color.white (px Constants.inputHeight) Constants.focusLighten - - (".confirm" <> ".undo") ? - width (px 90) diff --git a/server/src/Design/View/Header.hs b/server/src/Design/View/Header.hs deleted file mode 100644 index 2ad0455..0000000 --- a/server/src/Design/View/Header.hs +++ /dev/null @@ -1,93 +0,0 @@ -module Design.View.Header - ( design - ) where - -import Data.Monoid ((<>)) - -import Clay - -import Design.Color as Color -import qualified Design.Media as Media - -desktopLineHeight :: Double -desktopLineHeight = 80 - -tabletLineHeight :: Double -tabletLineHeight = 60 - -mobileLineHeight :: Double -mobileLineHeight = 40 - -design :: Css -design = do - display flex - "flex-wrap" -: "wrap" - position relative - backgroundColor Color.chestnutRose - color Color.white - - Media.desktop $ do - minHeight (px desktopLineHeight) - lineHeight (px desktopLineHeight) - marginBottom (em 3) - Media.tablet $ do - minHeight (px (tabletLineHeight * 2)) - lineHeight (px tabletLineHeight) - marginBottom (em 2) - Media.mobile $ do - minHeight (px (mobileLineHeight * 2)) - lineHeight (px mobileLineHeight) - marginBottom (em 1.5) - - ".title" <> ".item" ? do - Media.tabletDesktop $ sym2 padding (px 0) (px 20) - Media.mobile $ sym2 padding (px 0) (px 10) - - ".title" ? do - textAlign (alignSide sideLeft) - - Media.desktop $ do - fontSize (px 35) - display inlineBlock - Media.tablet $ do - fontSize (px 28) - display inlineBlock - width (pct 100) - Media.mobile $ do - fontSize (px 22) - width (pct 100) - - ".item" ? do - display inlineBlock - transition "background-color" (ms 50) easeIn (sec 0) - ".current" & backgroundColor (Color.chestnutRose -. 20) - Media.mobile $ fontSize (px 13) - - (".item" # hover) <> (".item" # focus) ? - backgroundColor (Color.chestnutRose +. 10) - - (".item.current" # hover) <> (".item.current" # focus) ? - backgroundColor (Color.chestnutRose -. 10) - - ".nameSignOut" ? do - display flex - position absolute - top (px 0) - right (px 0) - - Media.desktop $ height (px desktopLineHeight) - Media.tablet $ height (px tabletLineHeight) - Media.mobile $ height (px mobileLineHeight) - - ".name" ? do - Media.mobile $ display none - Media.tabletDesktop $ sym2 padding (px 0) (px 20) - - ".signOut" ? do - display flex - justifyContent center - alignItems center - svg ? do - Media.tabletDesktop $ width (px 30) - Media.mobile $ width (px 20) - "path" ? ("fill" -: "white") diff --git a/server/src/Design/View/NotFound.hs b/server/src/Design/View/NotFound.hs deleted file mode 100644 index 150c6fc..0000000 --- a/server/src/Design/View/NotFound.hs +++ /dev/null @@ -1,21 +0,0 @@ -module Design.View.NotFound - ( design - ) where - -import Clay -import Prelude hiding (rem) - -import qualified Design.Color as Color - -design :: Css -design = do - - marginLeft (rem 3) - - ".link" ? do - display block - marginTop (rem 1) - color Color.chestnutRose - textDecoration underline - hover & - color (Color.chestnutRose +. 15) diff --git a/server/src/Design/View/Pages.hs b/server/src/Design/View/Pages.hs deleted file mode 100644 index 1482ef4..0000000 --- a/server/src/Design/View/Pages.hs +++ /dev/null @@ -1,55 +0,0 @@ -module Design.View.Pages - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper -import qualified Design.Media as Media - -design :: Css -design = - ".pages" ? do - display flex - justifyContent center - - Media.desktop $ do - padding (px 40) (px 30) (px 30) (px 30) - - Media.tablet $ do - padding (px 30) (px 30) (px 30) (px 30) - - Media.mobile $ do - padding (px 20) (px 0) (px 20) (px 0) - lineHeight (px 40) - - svg ? "path" ? ("fill" -: Color.toString Color.dustyGray) - - ".page" ? do - display inlineBlock - fontWeight bold - - Media.desktop $ do - Helper.button Color.white Color.dustyGray (px 50) Constants.focusDarken - - Media.tabletDesktop $ do - border solid (px 2) Color.dustyGray - marginRight (px 10) - - Media.tablet $ do - Helper.button Color.white Color.dustyGray (px 40) Constants.focusDarken - fontSize (px 15) - - Media.mobile $ do - Helper.button Color.white Color.dustyGray (px 30) Constants.focusDarken - fontSize (px 12) - border solid (px 1) Color.dustyGray - marginRight (px 5) - - ":not(.current)" & cursor pointer - - ".current" & do - borderColor Color.chestnutRose - color Color.chestnutRose diff --git a/server/src/Design/View/Payment.hs b/server/src/Design/View/Payment.hs deleted file mode 100644 index 94e4f85..0000000 --- a/server/src/Design/View/Payment.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Design.View.Payment - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.View.Payment.HeaderForm as HeaderForm -import qualified Design.View.Payment.HeaderInfos as HeaderInfos - -design :: Css -design = do - HeaderForm.design - HeaderInfos.design - ".g-Payment__Refund" ? color Color.mossGreen diff --git a/server/src/Design/View/Payment/Add.hs b/server/src/Design/View/Payment/Add.hs deleted file mode 100644 index 5ecae7a..0000000 --- a/server/src/Design/View/Payment/Add.hs +++ /dev/null @@ -1,35 +0,0 @@ -module Design.View.Payment.Add - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper - -design :: Css -design = do - ".addHeader" ? do - backgroundColor Color.chestnutRose - fontSize (px 18) - color Color.white - sym2 padding (px 20) (px 30) - textAlign (alignSide sideCenter) - borderRadius (px 5) (px 5) (px 0) (px 0) - - ".addContent" ? do - sym2 padding (px 20) (px 30) - - ".buttons" ? do - display flex - justifyContent spaceAround - marginTop (em 1.5) - - ".confirm" ? - Helper.button Color.chestnutRose Color.white (px Constants.inputHeight) Constants.focusLighten - ".undo" ? - Helper.button Color.silver Color.white (px Constants.inputHeight) Constants.focusLighten - - (".confirm" <> ".undo") ? - width (px 90) diff --git a/server/src/Design/View/Payment/Form.hs b/server/src/Design/View/Payment/Form.hs deleted file mode 100644 index aada12b..0000000 --- a/server/src/Design/View/Payment/Form.hs +++ /dev/null @@ -1,35 +0,0 @@ -module Design.View.Payment.Form - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper - -design :: Css -design = do - ".formHeader" ? do - backgroundColor Color.chestnutRose - fontSize (px 18) - color Color.white - sym2 padding (px 20) (px 30) - textAlign (alignSide sideCenter) - borderRadius (px 5) (px 5) (px 0) (px 0) - - ".formContent" ? do - sym2 padding (px 20) (px 30) - - ".buttons" ? do - display flex - justifyContent spaceAround - marginTop (em 1.5) - - ".confirm" ? - Helper.button Color.chestnutRose Color.white (px Constants.inputHeight) Constants.focusLighten - ".undo" ? - Helper.button Color.silver Color.white (px Constants.inputHeight) Constants.focusLighten - - (".confirm" <> ".undo") ? - width (px 90) diff --git a/server/src/Design/View/Payment/HeaderForm.hs b/server/src/Design/View/Payment/HeaderForm.hs deleted file mode 100644 index 6081443..0000000 --- a/server/src/Design/View/Payment/HeaderForm.hs +++ /dev/null @@ -1,40 +0,0 @@ -module Design.View.Payment.HeaderForm - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper -import qualified Design.Media as Media - -design :: Css -design = do - - ".g-PaymentHeaderForm" ? do - marginBottom (em 2) - marginLeft (pct Constants.blockPercentMargin) - marginRight (pct Constants.blockPercentMargin) - display flex - justifyContent spaceBetween - alignItems center - Media.mobile $ flexDirection column - - ".textInput" ? do - display inlineBlock - marginBottom (px 0) - - Media.tabletDesktop $ marginRight (px 30) - Media.mobile $ do - marginBottom (em 1) - width (pct 100) - - ".selectInput" ? do - Media.tabletDesktop $ display inlineBlock - Media.mobile $ marginBottom (em 2) - - ".addPayment" ? do - Helper.button Color.chestnutRose Color.white (px Constants.inputHeight) Constants.focusLighten - Media.mobile $ width (pct 100) - flexShrink 0 diff --git a/server/src/Design/View/Payment/HeaderInfos.hs b/server/src/Design/View/Payment/HeaderInfos.hs deleted file mode 100644 index acb393b..0000000 --- a/server/src/Design/View/Payment/HeaderInfos.hs +++ /dev/null @@ -1,50 +0,0 @@ -module Design.View.Payment.HeaderInfos - ( design - ) where - -import Data.Monoid ((<>)) - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Media as Media - -design :: Css -design = do - - ".g-PaymentHeaderInfos" ? do - Media.desktop $ marginBottom (em 2) - Media.mobileTablet $ marginBottom (em 1) - marginLeft (pct Constants.blockPercentMargin) - marginRight (pct Constants.blockPercentMargin) - - ".g-PaymentHeaderInfos__ExceedingPayers" ? do - backgroundColor Color.mossGreen - borderRadius (px 5) (px 5) (px 5) (px 5) - color Color.white - lineHeight (px Constants.inputHeight) - paddingLeft (px 10) - paddingRight (px 10) - marginBottom (em 1) - - Media.mobile $ do - textAlign (alignSide sideCenter) - - ".exceedingPayer:not(:last-child)::after" ? content (stringContent ", ") - - ".userName" ? marginRight (px 8) - - ".g-PaymentHeaderInfos__Repartition" ? do - Media.tabletDesktop $ lineHeight (px Constants.inputHeight) - Media.mobile $ lineHeight (px 25) - - ".total" <> ".partition" ? do - Media.mobileTablet $ display block - Media.mobile $ do - fontSize (pct 90) - textAlign (alignSide sideCenter) - - ".partition" ? do - color Color.dustyGray - Media.desktop $ marginLeft (px 15) diff --git a/server/src/Design/View/SignIn.hs b/server/src/Design/View/SignIn.hs deleted file mode 100644 index 42c9621..0000000 --- a/server/src/Design/View/SignIn.hs +++ /dev/null @@ -1,36 +0,0 @@ -module Design.View.SignIn - ( design - ) where - -import Clay -import Data.Monoid ((<>)) -import Prelude hiding (rem) - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper - -design :: Css -design = do - let inputHeight = 50 - width (px 350) - sym2 padding (rem 0) (rem 2) - marginTop (px 100) - marginLeft auto - marginRight auto - - button # ".validate" ? do - Helper.button Color.gothic Color.white (px inputHeight) Constants.focusLighten - display flex - alignItems center - justifyContent center - width (pct 100) - fontSize (em 1.2) - svg ? "path" ? ("fill" -: "white") - - ".success" <> ".error" ? do - marginTop (px 40) - textAlign (alignSide sideCenter) - - ".success" ? color Color.mossGreen - ".error" ? color Color.chestnutRose diff --git a/server/src/Design/View/Stat.hs b/server/src/Design/View/Stat.hs deleted file mode 100644 index 2e4ecad..0000000 --- a/server/src/Design/View/Stat.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Design.View.Stat - ( design - ) where - -import Clay - -design :: Css -design = do - h1 ? paddingBottom (px 0) - - ".exceedingPayers" ? ".userName" ? marginRight (px 5) - - ".mean" ? marginBottom (em 1.5) - - ".g-Chart" ? do - width (pct 75) - sym2 margin (px 0) auto diff --git a/server/src/Design/View/Table.hs b/server/src/Design/View/Table.hs deleted file mode 100644 index 56bd389..0000000 --- a/server/src/Design/View/Table.hs +++ /dev/null @@ -1,99 +0,0 @@ -module Design.View.Table - ( design - ) where - -import Data.Monoid ((<>)) - -import Clay - -import Design.Color as Color -import qualified Design.Media as Media - -design :: Css -design = do - ".emptyTableMsg" ? do - margin (em 2) (em 2) (em 2) (em 2) - textAlign (alignSide sideCenter) - - ".table" ? do - minHeight (px 540) - - ".lines" ? do - Media.tabletDesktop $ display displayTable - width (pct 100) - textAlign (alignSide (sideCenter)) - - ".header" <> ".row" ? do - Media.tabletDesktop $ display tableRow - - ".header" ? do - Media.desktop $ do - fontSize (px 18) - height (px 70) - - Media.tabletDesktop $ do - backgroundColor Color.gothic - color Color.white - - Media.tablet $ do - fontSize (px 16) - height (px 60) - - Media.mobile $ do - display none - - ".row" ? do - nthChild "even" & backgroundColor Color.wildSand - - Media.desktop $ do - fontSize (px 18) - height (px 60) - - Media.tablet $ do - height (px 50) - - Media.mobile $ do - lineHeight (px 25) - paddingTop (px 10) - paddingBottom (px 10) - - ".cell" ? do - Media.tabletDesktop $ display tableCell - position relative - verticalAlign middle - - firstChild & do - Media.mobile $ do - fontSize (px 20) - lineHeight (px 30) - color Color.gothic - - ".refund" & color Color.mossGreen - - Media.desktop $ do - ".shortDate" ? display none - ".longDate" ? display inline - Media.tablet $ do - ".shortDate" ? display inline - ".longDate" ? display none - Media.mobile $ do - ".shortDate" ? display none - ".longDate" ? display inline - marginBottom (em 0.5) - - ".cell.button" & do - position relative - textAlign (alignSide sideCenter) - button ? do - padding (px 10) (px 10) (px 10) (px 10) - svg ? do - "path" ? ("fill" -: Color.toString Color.chestnutRose) - width (px 18) - hover & "svg path" ? do - "fill" -: "rgb(237, 122, 116)" - - Media.tabletDesktop $ width (pct 3) - - Media.mobile $ do - display inlineBlock - button ? display flex diff --git a/server/src/Design/Views.hs b/server/src/Design/Views.hs deleted file mode 100644 index 4552796..0000000 --- a/server/src/Design/Views.hs +++ /dev/null @@ -1,56 +0,0 @@ -module Design.Views - ( design - ) where - -import Clay - -import qualified Design.Color as Color -import qualified Design.Constants as Constants -import qualified Design.Helper as Helper -import qualified Design.Media as Media -import qualified Design.View.ConfirmDialog as ConfirmDialog -import qualified Design.View.Header as Header -import qualified Design.View.NotFound as NotFound -import qualified Design.View.Pages as Pages -import qualified Design.View.Payment as Payment -import qualified Design.View.SignIn as SignIn -import qualified Design.View.Stat as Stat -import qualified Design.View.Table as Table - -design :: Css -design = do - header ? Header.design - Payment.design - ".signIn" ? SignIn.design - Stat.design - ".notfound" ? NotFound.design - Table.design - Pages.design - ConfirmDialog.design - - ".withMargin" ? do - "margin" -: "0 2vw" - - ".titleButton" ? do - display flex - marginBottom (em 1) - - Media.tabletDesktop $ do - justifyContent spaceBetween - alignItems center - - Media.mobile $ do - flexDirection column - "h1" ? marginBottom (em 0.5) - - button ? do - Helper.button Color.chestnutRose Color.white (px Constants.inputHeight) Constants.focusLighten - Media.mobile $ do - width (pct 100) - marginBottom (px 20) - - ".tag" ? do - sym borderRadius (px 4) - sym2 padding (px 2) (px 5) - boxShadow . pure . bsColor (rgba 0 0 0 0.3) $ shadowWithBlur (px 2) (px 2) (px 5) - color Color.white diff --git a/server/src/Job/Daemon.hs b/server/src/Job/Daemon.hs deleted file mode 100644 index d8cd522..0000000 --- a/server/src/Job/Daemon.hs +++ /dev/null @@ -1,37 +0,0 @@ -module Job.Daemon - ( runDaemons - ) where - -import Control.Concurrent (ThreadId, forkIO, threadDelay) -import Control.Monad (forever) -import Data.Time.Clock (UTCTime) - -import Conf (Conf) -import Job.Frequency (Frequency (..), microSeconds) -import Job.Kind (Kind (..)) -import Job.Model (actualizeLastCheck, actualizeLastExecution, - getLastExecution) -import Job.MonthlyPayment (monthlyPayment) -import Job.WeeklyReport (weeklyReport) -import qualified Model.Query as Query -import Util.Time (belongToCurrentMonth, belongToCurrentWeek) - -runDaemons :: Conf -> IO () -runDaemons conf = do - _ <- runDaemon MonthlyPayment EveryHour (fmap not . belongToCurrentMonth) monthlyPayment - _ <- runDaemon WeeklyReport EveryHour (fmap not . belongToCurrentWeek) (weeklyReport conf) - return () - -runDaemon :: Kind -> Frequency -> (UTCTime -> IO Bool) -> (Maybe UTCTime -> IO UTCTime) -> IO ThreadId -runDaemon kind frequency isLastExecutionTooOld runJob = - forkIO . forever $ do - mbLastExecution <- Query.run $ do - actualizeLastCheck kind - getLastExecution kind - hasToRun <- case mbLastExecution of - Just lastExecution -> isLastExecutionTooOld lastExecution - Nothing -> return True - if hasToRun - then runJob mbLastExecution >>= (Query.run . actualizeLastExecution kind) - else return () - threadDelay . microSeconds $ frequency diff --git a/server/src/Job/Frequency.hs b/server/src/Job/Frequency.hs deleted file mode 100644 index c5bef42..0000000 --- a/server/src/Job/Frequency.hs +++ /dev/null @@ -1,13 +0,0 @@ -module Job.Frequency - ( Frequency(..) - , microSeconds - ) where - -data Frequency = - EveryHour - | EveryDay - deriving (Eq, Read, Show) - -microSeconds :: Frequency -> Int -microSeconds EveryHour = 1000000 * 60 * 60 -microSeconds EveryDay = (microSeconds EveryHour) * 24 diff --git a/server/src/Job/Kind.hs b/server/src/Job/Kind.hs deleted file mode 100644 index 17997f7..0000000 --- a/server/src/Job/Kind.hs +++ /dev/null @@ -1,23 +0,0 @@ -module Job.Kind - ( Kind(..) - ) where - -import qualified Data.Text as T -import Database.SQLite.Simple (SQLData (SQLText)) -import Database.SQLite.Simple.FromField (FromField (fromField), - fieldData) -import Database.SQLite.Simple.Ok (Ok (Errors, Ok)) -import Database.SQLite.Simple.ToField (ToField (toField)) - -data Kind = - MonthlyPayment - | WeeklyReport - deriving (Eq, Show, Read) - -instance FromField Kind where - fromField field = case fieldData field of - SQLText text -> Ok (read (T.unpack text) :: Kind) - _ -> Errors [error "SQLText field required for job kind"] - -instance ToField Kind where - toField kind = SQLText . T.pack . show $ kind diff --git a/server/src/Job/Model.hs b/server/src/Job/Model.hs deleted file mode 100644 index 1dd6c63..0000000 --- a/server/src/Job/Model.hs +++ /dev/null @@ -1,49 +0,0 @@ -module Job.Model - ( Job(..) - , getLastExecution - , actualizeLastExecution - , actualizeLastCheck - ) where - -import Data.Time.Clock (UTCTime, getCurrentTime) -import Database.SQLite.Simple (Only (Only)) -import qualified Database.SQLite.Simple as SQLite -import Prelude hiding (id) - -import Job.Kind -import Model.Query (Query (Query)) - -data Job = Job - { id :: String - , kind :: Kind - , lastExecution :: Maybe UTCTime - , lastCheck :: Maybe UTCTime - } deriving (Show) - -getLastExecution :: Kind -> Query (Maybe UTCTime) -getLastExecution jobKind = - Query (\conn -> do - result <- SQLite.query conn "SELECT last_execution FROM job WHERE kind = ?" (Only jobKind) :: IO [Only UTCTime] - return $ case result of - [Only time] -> Just time - _ -> Nothing - ) - -actualizeLastExecution :: Kind -> UTCTime -> Query () -actualizeLastExecution jobKind time = - Query (\conn -> do - result <- SQLite.query conn "SELECT 1 FROM job WHERE kind = ?" (Only jobKind) :: IO [Only Int] - let hasJob = case result of - [Only _] -> True - _ -> False - if hasJob - then SQLite.execute conn "UPDATE job SET last_execution = ? WHERE kind = ?" (time, jobKind) - else SQLite.execute conn "INSERT INTO job (kind, last_execution, last_check) VALUES (?, ?, ?)" (jobKind, time, time) - ) - -actualizeLastCheck :: Kind -> Query () -actualizeLastCheck jobKind = - Query (\conn -> do - now <- getCurrentTime - SQLite.execute conn "UPDATE job SET kind = ? WHERE last_check = ?" (jobKind, now) - ) diff --git a/server/src/Job/MonthlyPayment.hs b/server/src/Job/MonthlyPayment.hs deleted file mode 100644 index dfbe8b4..0000000 --- a/server/src/Job/MonthlyPayment.hs +++ /dev/null @@ -1,26 +0,0 @@ -module Job.MonthlyPayment - ( monthlyPayment - ) where - -import Data.Time.Clock (UTCTime, getCurrentTime) - -import Common.Model (Frequency (..), Payment (..)) -import qualified Common.Util.Time as Time - -import qualified Model.Query as Query -import qualified Persistence.Payment as PaymentPersistence - -monthlyPayment :: Maybe UTCTime -> IO UTCTime -monthlyPayment _ = do - monthlyPayments <- Query.run PaymentPersistence.listActiveMonthlyOrderedByName - now <- getCurrentTime - actualDay <- Time.timeToDay now - let punctualPayments = map - (\p -> p - { _payment_frequency = Punctual - , _payment_date = actualDay - , _payment_createdAt = now - }) - monthlyPayments - _ <- Query.run (PaymentPersistence.createMany punctualPayments) - return now diff --git a/server/src/Job/WeeklyReport.hs b/server/src/Job/WeeklyReport.hs deleted file mode 100644 index 282f2f1..0000000 --- a/server/src/Job/WeeklyReport.hs +++ /dev/null @@ -1,52 +0,0 @@ -module Job.WeeklyReport - ( weeklyReport - ) where - -import qualified Data.Map as M -import qualified Data.Time.Clock as Clock -import Data.Time.Clock (UTCTime, getCurrentTime) - -import Common.Model (User (..)) - -import Conf (Conf) -import qualified Model.Query as Query -import qualified Persistence.Income as IncomePersistence -import qualified Persistence.Payment as PaymentPersistence -import qualified Persistence.User as UserPersistence -import qualified SendMail -import qualified View.Mail.WeeklyReport as WeeklyReport - -weeklyReport :: Conf -> Maybe UTCTime -> IO UTCTime -weeklyReport conf mbLastExecution = do - now <- getCurrentTime - - case mbLastExecution of - Nothing -> - return () - - Just lastExecution -> do - (weekPayments, cumulativeIncome, preIncomeRepartition, postIncomeRepartition, weekIncomes, users) <- Query.run $ do - users <- UserPersistence.list - paymentRange <- PaymentPersistence.getRange - incomeDefinedForAll <- IncomePersistence.definedForAll (_user_id <$> users) - cumulativeIncome <- - case (incomeDefinedForAll, paymentRange) of - (Just incomeStart, Just (paymentStart, _)) -> - IncomePersistence.getCumulativeIncome (max incomeStart paymentStart) (Clock.utctDay now) - - _ -> - return M.empty - weekPayments <- PaymentPersistence.listModifiedPunctualSince lastExecution - weekIncomes <- IncomePersistence.listModifiedSince lastExecution - (preIncomeRepartition, postIncomeRepartition) <- - PaymentPersistence.getPreAndPostPaymentRepartition paymentRange users - return (weekPayments, cumulativeIncome, preIncomeRepartition, postIncomeRepartition, weekIncomes, users) - - _ <- - SendMail.sendMail - conf - (WeeklyReport.mail conf users weekIncomes weekPayments cumulativeIncome preIncomeRepartition postIncomeRepartition lastExecution now) - - return () - - return now diff --git a/server/src/LoginSession.hs b/server/src/LoginSession.hs deleted file mode 100644 index 86f1329..0000000 --- a/server/src/LoginSession.hs +++ /dev/null @@ -1,52 +0,0 @@ -module LoginSession - ( put - , get - , delete - ) where - -import Cookie (deleteCookie, getCookie, - setSimpleCookie) -import qualified Web.ClientSession as CS -import Web.Scotty (ActionM) - -import Control.Monad.IO.Class (liftIO) - -import Data.Text (Text) -import qualified Data.Text.Encoding as TE - -import Conf (Conf) - -sessionName :: Text -sessionName = "SESSION" - -sessionKeyFile :: FilePath -sessionKeyFile = "sessionKey" - -put :: Conf -> Text -> ActionM () -put conf value = do - encrypted <- liftIO $ encrypt value - setSimpleCookie conf sessionName encrypted - -encrypt :: Text -> IO Text -encrypt value = do - iv <- CS.randomIV - key <- CS.getKey sessionKeyFile - return . TE.decodeUtf8 $ CS.encrypt key iv (TE.encodeUtf8 value) - -get :: ActionM (Maybe Text) -get = do - maybeEncrypted <- getCookie sessionName - case maybeEncrypted of - Just encrypted -> - liftIO $ decrypt encrypted - Nothing -> - return Nothing - -decrypt :: Text -> IO (Maybe Text) -decrypt encrypted = do - key <- CS.getKey sessionKeyFile - let decrypted = TE.decodeUtf8 <$> CS.decrypt key (TE.encodeUtf8 encrypted) - return decrypted - -delete :: Conf -> ActionM () -delete conf = deleteCookie conf sessionName diff --git a/server/src/Main.hs b/server/src/Main.hs deleted file mode 100644 index 659a0fa..0000000 --- a/server/src/Main.hs +++ /dev/null @@ -1,106 +0,0 @@ -module Main - ( main - ) where - -import qualified Network.HTTP.Types.Status as Status -import Network.Wai.Middleware.Gzip (GzipFiles (GzipCompress)) -import qualified Network.Wai.Middleware.Gzip as W -import Network.Wai.Middleware.Static -import qualified Web.Scotty as S - -import qualified Conf -import qualified Controller.Category as Category -import qualified Controller.Income as Income -import qualified Controller.Index as Index -import qualified Controller.Payment as Payment -import qualified Controller.Statistics as Statistics -import qualified Controller.User as User -import qualified Design.Global as Design -import Job.Daemon (runDaemons) - -main :: IO () -main = do - conf <- Conf.get "application.conf" - putStrLn . show $ conf - _ <- runDaemons conf - S.scotty (Conf.port conf) $ do - - S.middleware $ - W.gzip $ W.def { W.gzipFiles = GzipCompress } - - S.middleware . staticPolicy $ - noDots >-> addBase "public" - - S.get "/css/main.css" $ do - S.setHeader "Content-Type" "text/css" - S.text Design.globalDesign - - S.post "/api/signIn" $ - S.jsonData >>= Index.signIn conf - - S.post "/api/signOut" $ - Index.signOut conf - - S.get "/api/users"$ - User.list - - S.get "/api/payments" $ do - frequency <- S.param "frequency" - page <- S.param "page" - perPage <- S.param "perPage" - search <- S.param "search" - Payment.list (read frequency) page perPage search - - S.get "/api/payment/category" $ do - name <- S.param "name" - Payment.searchCategory name - - S.post "/api/payment" $ - S.jsonData >>= Payment.create - - S.put "/api/payment" $ - S.jsonData >>= Payment.edit - - S.delete "/api/payment/:id" $ do - paymentId <- S.param "id" - Payment.delete paymentId - - S.get "/api/incomes" $ do - page <- S.param "page" - perPage <- S.param "perPage" - Income.list page perPage - - S.post "/api/income" $ - S.jsonData >>= Income.create - - S.put "/api/income" $ - S.jsonData >>= Income.edit - - S.delete "/api/income/:id" $ do - incomeId <- S.param "id" - Income.delete incomeId - - S.get "/api/allCategories" $ do - Category.listAll - - S.get "/api/categories" $ do - page <- S.param "page" - perPage <- S.param "perPage" - Category.list page perPage - - S.post "/api/category" $ - S.jsonData >>= Category.create - - S.put "/api/category" $ - S.jsonData >>= Category.edit - - S.delete "/api/category/:id" $ do - categoryId <- S.param "id" - Category.delete categoryId - - S.get "/api/statistics" $ do - Statistics.paymentsAndIncomes - - S.notFound $ do - S.status Status.ok200 - Index.get conf diff --git a/server/src/Model/CreateCategory.hs b/server/src/Model/CreateCategory.hs deleted file mode 100644 index dae061b..0000000 --- a/server/src/Model/CreateCategory.hs +++ /dev/null @@ -1,10 +0,0 @@ -module Model.CreateCategory - ( CreateCategory(..) - ) where - -import Data.Text (Text) - -data CreateCategory = CreateCategory - { _createCategory_name :: Text - , _createCategory_color :: Text - } deriving (Show) diff --git a/server/src/Model/CreateIncome.hs b/server/src/Model/CreateIncome.hs deleted file mode 100644 index 82451d2..0000000 --- a/server/src/Model/CreateIncome.hs +++ /dev/null @@ -1,10 +0,0 @@ -module Model.CreateIncome - ( CreateIncome(..) - ) where - -import Data.Time.Calendar (Day) - -data CreateIncome = CreateIncome - { _createIncome_amount :: Int - , _createIncome_date :: Day - } deriving (Show) diff --git a/server/src/Model/CreatePayment.hs b/server/src/Model/CreatePayment.hs deleted file mode 100644 index b25d2a4..0000000 --- a/server/src/Model/CreatePayment.hs +++ /dev/null @@ -1,16 +0,0 @@ -module Model.CreatePayment - ( CreatePayment(..) - ) where - -import Data.Text (Text) -import Data.Time.Calendar (Day) - -import Common.Model (CategoryId, Frequency) - -data CreatePayment = CreatePayment - { _createPayment_name :: Text - , _createPayment_cost :: Int - , _createPayment_date :: Day - , _createPayment_category :: CategoryId - , _createPayment_frequency :: Frequency - } deriving (Show) diff --git a/server/src/Model/EditCategory.hs b/server/src/Model/EditCategory.hs deleted file mode 100644 index 8ee26ac..0000000 --- a/server/src/Model/EditCategory.hs +++ /dev/null @@ -1,13 +0,0 @@ -module Model.EditCategory - ( EditCategory(..) - ) where - -import Data.Text (Text) - -import Common.Model (CategoryId) - -data EditCategory = EditCategory - { _editCategory_id :: CategoryId - , _editCategory_name :: Text - , _editCategory_color :: Text - } deriving (Show) diff --git a/server/src/Model/EditIncome.hs b/server/src/Model/EditIncome.hs deleted file mode 100644 index ac3d311..0000000 --- a/server/src/Model/EditIncome.hs +++ /dev/null @@ -1,13 +0,0 @@ -module Model.EditIncome - ( EditIncome(..) - ) where - -import Data.Time.Calendar (Day) - -import Common.Model (IncomeId) - -data EditIncome = EditIncome - { _editIncome_id :: IncomeId - , _editIncome_amount :: Int - , _editIncome_date :: Day - } deriving (Show) diff --git a/server/src/Model/EditPayment.hs b/server/src/Model/EditPayment.hs deleted file mode 100644 index ac4c906..0000000 --- a/server/src/Model/EditPayment.hs +++ /dev/null @@ -1,17 +0,0 @@ -module Model.EditPayment - ( EditPayment(..) - ) where - -import Data.Text (Text) -import Data.Time.Calendar (Day) - -import Common.Model (CategoryId, Frequency, PaymentId) - -data EditPayment = EditPayment - { _editPayment_id :: PaymentId - , _editPayment_name :: Text - , _editPayment_cost :: Int - , _editPayment_date :: Day - , _editPayment_category :: CategoryId - , _editPayment_frequency :: Frequency - } deriving (Show) diff --git a/server/src/Model/HashedPassword.hs b/server/src/Model/HashedPassword.hs deleted file mode 100644 index c71e372..0000000 --- a/server/src/Model/HashedPassword.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Model.HashedPassword - ( hash - , check - , HashedPassword(..) - ) where - -import qualified Crypto.BCrypt as BCrypt -import Data.Text (Text) -import qualified Data.Text.Encoding as TE - -import Common.Model.Password (Password (..)) - -newtype HashedPassword = HashedPassword Text deriving (Show) - -hash :: Password -> IO (Maybe HashedPassword) -hash (Password p) = do - hashed <- BCrypt.hashPasswordUsingPolicy BCrypt.slowerBcryptHashingPolicy (TE.encodeUtf8 p) - case hashed of - Nothing -> - return Nothing - - Just h -> - return . Just . HashedPassword . TE.decodeUtf8 $ h - -check :: Password -> HashedPassword -> Bool -check (Password p) (HashedPassword h) = - BCrypt.validatePassword (TE.encodeUtf8 h) (TE.encodeUtf8 p) diff --git a/server/src/Model/IncomeResource.hs b/server/src/Model/IncomeResource.hs deleted file mode 100644 index 6ab5f18..0000000 --- a/server/src/Model/IncomeResource.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Model.IncomeResource - ( IncomeResource(..) - ) where - -import Common.Model (Income (..)) - -import Resource (Resource, resourceCreatedAt, resourceDeletedAt, - resourceEditedAt) - -newtype IncomeResource = IncomeResource Income - -instance Resource IncomeResource where - resourceCreatedAt (IncomeResource i) = _income_createdAt i - resourceEditedAt (IncomeResource i) = _income_editedAt i - resourceDeletedAt (IncomeResource i) = _income_deletedAt i diff --git a/server/src/Model/Mail.hs b/server/src/Model/Mail.hs deleted file mode 100644 index 780efcc..0000000 --- a/server/src/Model/Mail.hs +++ /dev/null @@ -1,12 +0,0 @@ -module Model.Mail - ( Mail(..) - ) where - -import Data.Text (Text) - -data Mail = Mail - { from :: Text - , to :: [Text] - , subject :: Text - , body :: Text - } deriving (Eq, Show) diff --git a/server/src/Model/PaymentResource.hs b/server/src/Model/PaymentResource.hs deleted file mode 100644 index 1ea978c..0000000 --- a/server/src/Model/PaymentResource.hs +++ /dev/null @@ -1,15 +0,0 @@ -module Model.PaymentResource - ( PaymentResource(..) - ) where - -import Common.Model (Payment (..)) - -import Resource (Resource, resourceCreatedAt, resourceDeletedAt, - resourceEditedAt) - -newtype PaymentResource = PaymentResource Payment - -instance Resource PaymentResource where - resourceCreatedAt (PaymentResource p) = _payment_createdAt p - resourceEditedAt (PaymentResource p) = _payment_editedAt p - resourceDeletedAt (PaymentResource p) = _payment_deletedAt p diff --git a/server/src/Model/Query.hs b/server/src/Model/Query.hs deleted file mode 100644 index 22ae95b..0000000 --- a/server/src/Model/Query.hs +++ /dev/null @@ -1,32 +0,0 @@ -module Model.Query - ( Query(..) - , run - ) where - -import Data.Functor (Functor) -import Database.SQLite.Simple (Connection) -import qualified Database.SQLite.Simple as SQLite - -data Query a = Query (Connection -> IO a) - -instance Functor Query where - fmap f (Query call) = Query (fmap f . call) - -instance Applicative Query where - pure x = Query (const $ return x) - (Query callF) <*> (Query callX) = Query (\conn -> do - x <- callX conn - f <- callF conn - return (f x)) - -instance Monad Query where - (Query callX) >>= f = Query (\conn -> do - x <- callX conn - case f x of Query callY -> callY conn) - -run :: Query a -> IO a -run (Query call) = do - conn <- SQLite.open "database" - result <- call conn - _ <- SQLite.close conn - return result diff --git a/server/src/Model/SignIn.hs b/server/src/Model/SignIn.hs deleted file mode 100644 index a217bae..0000000 --- a/server/src/Model/SignIn.hs +++ /dev/null @@ -1,10 +0,0 @@ -module Model.SignIn - ( SignIn(..) - ) where - -import Common.Model (Email, Password) - -data SignIn = SignIn - { _signIn_email :: Email - , _signIn_password :: Password - } deriving Show diff --git a/server/src/Model/UUID.hs b/server/src/Model/UUID.hs deleted file mode 100644 index 0959a8e..0000000 --- a/server/src/Model/UUID.hs +++ /dev/null @@ -1,10 +0,0 @@ -module Model.UUID - ( generateUUID - ) where - -import Data.Text (Text, pack) -import Data.UUID (toString) -import Data.UUID.V4 (nextRandom) - -generateUUID :: IO Text -generateUUID = pack . toString <$> nextRandom diff --git a/server/src/Payer.hs b/server/src/Payer.hs deleted file mode 100644 index ab8312e..0000000 --- a/server/src/Payer.hs +++ /dev/null @@ -1,87 +0,0 @@ -module Payer - ( getExceedingPayers - ) where - -import Data.Map (Map) -import qualified Data.Map as M - -import Common.Model (ExceedingPayer (..), User (..), UserId) - -data Payer = Payer - { _payer_userId :: UserId - , _payer_preIncomePayments :: Int - , _payer_postIncomePayments :: Int - , _payer_income :: Int - } - -data PostPaymentPayer = PostPaymentPayer - { _postPaymentPayer_userId :: UserId - , _postPaymentPayer_preIncomePayments :: Int - , _postPaymentPayer_cumulativeIncome :: Int - , _postPaymentPayer_ratio :: Float - } - -getExceedingPayers :: [User] -> Map UserId Int -> Map UserId Int -> Map UserId Int -> [ExceedingPayer] -getExceedingPayers users cumulativeIncome preIncomeRepartition postIncomeRepartition = - let userIds = map _user_id users - payers = getPayers userIds cumulativeIncome preIncomeRepartition postIncomeRepartition - postPaymentPayers = map getPostPaymentPayer payers - mbMaxRatio = safeMaximum . map _postPaymentPayer_ratio $ postPaymentPayers - in case mbMaxRatio of - Just maxRatio -> - exceedingPayersFromAmounts - . map (\p -> (_postPaymentPayer_userId p, getFinalDiff maxRatio p)) - $ postPaymentPayers - Nothing -> - exceedingPayersFromAmounts - . map (\p -> (_payer_userId p, _payer_preIncomePayments p)) - $ payers - -getPayers :: [UserId] -> Map UserId Int -> Map UserId Int -> Map UserId Int -> [Payer] -getPayers userIds cumulativeIncome preIncomeRepartition postIncomeRepartition = - flip map userIds (\userId -> Payer - { _payer_userId = userId - , _payer_preIncomePayments = M.findWithDefault 0 userId preIncomeRepartition - , _payer_postIncomePayments = M.findWithDefault 0 userId postIncomeRepartition - , _payer_income = M.findWithDefault 0 userId cumulativeIncome - } - ) - -exceedingPayersFromAmounts :: [(UserId, Int)] -> [ExceedingPayer] -exceedingPayersFromAmounts userAmounts = - case mbMinAmount of - Nothing -> - [] - Just minAmount -> - filter (\payer -> _exceedingPayer_amount payer > 0) - . map (\userAmount -> - ExceedingPayer - { _exceedingPayer_userId = fst userAmount - , _exceedingPayer_amount = snd userAmount - minAmount - } - ) - $ userAmounts - where mbMinAmount = safeMinimum . map snd $ userAmounts - -getPostPaymentPayer :: Payer -> PostPaymentPayer -getPostPaymentPayer payer = - PostPaymentPayer - { _postPaymentPayer_userId = _payer_userId payer - , _postPaymentPayer_preIncomePayments = _payer_preIncomePayments payer - , _postPaymentPayer_cumulativeIncome = _payer_income payer - , _postPaymentPayer_ratio = (fromIntegral . _payer_postIncomePayments $ payer) / (fromIntegral $ _payer_income payer) - } - -getFinalDiff :: Float -> PostPaymentPayer -> Int -getFinalDiff maxRatio payer = - let postIncomeDiff = - truncate $ -1.0 * (maxRatio - _postPaymentPayer_ratio payer) * (fromIntegral . _postPaymentPayer_cumulativeIncome $ payer) - in postIncomeDiff + _postPaymentPayer_preIncomePayments payer - -safeMinimum :: (Ord a) => [a] -> Maybe a -safeMinimum [] = Nothing -safeMinimum xs = Just . minimum $ xs - -safeMaximum :: (Ord a) => [a] -> Maybe a -safeMaximum [] = Nothing -safeMaximum xs = Just . maximum $ xs diff --git a/server/src/Persistence/Category.hs b/server/src/Persistence/Category.hs deleted file mode 100644 index b0a6fca..0000000 --- a/server/src/Persistence/Category.hs +++ /dev/null @@ -1,123 +0,0 @@ -module Persistence.Category - ( count - , list - , listAll - , create - , edit - , delete - ) where - -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import Data.Time.Clock (getCurrentTime) -import Database.SQLite.Simple (FromRow (fromRow), NamedParam ((:=))) -import qualified Database.SQLite.Simple as SQLite -import Prelude hiding (id) - -import Common.Model (Category (..), CategoryId) - -import Model.Query (Query (Query)) - -newtype Row = Row Category - -instance FromRow Row where - fromRow = Row <$> (Category <$> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field) - -data CountRow = CountRow Int - -instance FromRow CountRow where - fromRow = CountRow <$> SQLite.field - -count :: Query Int -count = - Query (\conn -> - (Maybe.fromMaybe 0 . fmap (\(CountRow n) -> n) . Maybe.listToMaybe) <$> - SQLite.query_ conn "SELECT COUNT(*) FROM category WHERE deleted_at IS NULL" - ) - - -list :: Int -> Int -> Query [Category] -list page perPage = - Query (\conn -> - map (\(Row c) -> c) <$> - SQLite.queryNamed - conn - "SELECT * FROM category WHERE deleted_at IS NULL ORDER BY name LIMIT :limit OFFSET :offset" - [ ":limit" := perPage - , ":offset" := (page - 1) * perPage - ] - ) - -listAll :: Query [Category] -listAll = - Query (\conn -> - map (\(Row c) -> c) <$> - SQLite.query_ conn "SELECT * FROM category WHERE deleted_at IS NULL" - ) - -create :: Text -> Text -> Query () -create name color = - Query (\conn -> do - currentTime <- getCurrentTime - SQLite.executeNamed - conn - "INSERT INTO category (name, color, created_at) VALUES (:name, :color, :created_at)" - [ ":name" := name - , ":color" := color - , ":created_at" := currentTime - ] - ) - -edit :: CategoryId -> Text -> Text -> Query Bool -edit id name color = - Query (\conn -> do - mbCategory <- fmap (\(Row c) -> c) . Maybe.listToMaybe <$> - (SQLite.queryNamed conn "SELECT * FROM category WHERE id = :id" [ ":id" := id ]) - if Maybe.isJust mbCategory - then do - currentTime <- getCurrentTime - SQLite.executeNamed - conn - "UPDATE category SET edited_at = :editedAt, name = :name, color = :color WHERE id = :id" - [ ":editedAt" := currentTime - , ":name" := name - , ":color" := color - , ":id" := id - ] - return True - else - return False - ) - -data BoolRow = BoolRow Int - -instance FromRow BoolRow where - fromRow = BoolRow <$> SQLite.field - -delete :: CategoryId -> Query Bool -delete id = - Query (\conn -> do - mbPayment <- (fmap (\(BoolRow b) -> b) . Maybe.listToMaybe) <$> - (SQLite.queryNamed - conn - "SELECT true FROM payment WHERE category = :id AND deleted_at IS NULL" - [ ":id" := id ]) - if Maybe.isNothing mbPayment - then do - currentTime <- getCurrentTime - SQLite.executeNamed - conn - "UPDATE category SET deleted_at = :deletedAt WHERE id = :id AND deleted_at IS NULL" - [ ":deletedAt" := currentTime - , ":id" := id - ] - return True - else - return False - ) diff --git a/server/src/Persistence/Frequency.hs b/server/src/Persistence/Frequency.hs deleted file mode 100644 index edaa844..0000000 --- a/server/src/Persistence/Frequency.hs +++ /dev/null @@ -1,23 +0,0 @@ -module Persistence.Frequency - ( FrequencyField(..) - ) where - -import qualified Data.Text as T -import Database.SQLite.Simple (SQLData (SQLText)) -import Database.SQLite.Simple.FromField (FromField (fromField), - fieldData) -import Database.SQLite.Simple.Ok (Ok (Errors, Ok)) -import Database.SQLite.Simple.ToField (ToField (toField)) - -import Common.Model (Frequency) - -newtype FrequencyField = FrequencyField Frequency - -instance FromField FrequencyField where - fromField field = - case fieldData field of - SQLText text -> Ok (FrequencyField (read (T.unpack text) :: Frequency)) - _ -> Errors [error "SQLText field required for frequency"] - -instance ToField FrequencyField where - toField (FrequencyField f) = SQLText . T.pack . show $ f diff --git a/server/src/Persistence/Income.hs b/server/src/Persistence/Income.hs deleted file mode 100644 index 1b5364c..0000000 --- a/server/src/Persistence/Income.hs +++ /dev/null @@ -1,201 +0,0 @@ -module Persistence.Income - ( listAll - , count - , list - , listModifiedSince - , create - , edit - , delete - , definedForAll - , getCumulativeIncome - ) where - -import qualified Data.List as L -import Data.Map (Map) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import qualified Data.Text as T -import Data.Time.Calendar (Day) -import Data.Time.Clock (UTCTime) -import Data.Time.Clock (getCurrentTime) -import Database.SQLite.Simple (FromRow (fromRow), NamedParam ((:=))) -import qualified Database.SQLite.Simple as SQLite -import Prelude hiding (id, until) - -import Common.Model (Income (..), IncomeId, PaymentId, - UserId) - -import Model.Query (Query (Query)) - -newtype Row = Row Income - -instance FromRow Row where - fromRow = Row <$> (Income <$> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field) - -data CountRow = CountRow Int - -instance FromRow CountRow where - fromRow = CountRow <$> SQLite.field - -listAll :: Query [Income] -listAll = - Query (\conn -> - map (\(Row i) -> i) <$> - SQLite.query_ - conn - "SELECT * FROM income WHERE deleted_at IS NULL ORDER BY date DESC" - ) - - -count :: Query Int -count = - Query (\conn -> - (Maybe.fromMaybe 0 . fmap (\(CountRow n) -> n) . Maybe.listToMaybe) <$> - SQLite.query_ conn "SELECT COUNT(*) FROM income WHERE deleted_at IS NULL" - ) - -list :: Int -> Int -> Query [Income] -list page perPage = - Query (\conn -> - map (\(Row i) -> i) <$> - SQLite.queryNamed - conn - "SELECT * FROM income WHERE deleted_at IS NULL ORDER BY date DESC LIMIT :limit OFFSET :offset" - [ ":limit" := perPage - , ":offset" := (page - 1) * perPage - ] - ) - -listModifiedSince :: UTCTime -> Query [Income] -listModifiedSince since = - Query (\conn -> - map (\(Row i) -> i) <$> - SQLite.queryNamed - conn - (SQLite.Query . T.intercalate " " $ - [ "SELECT *" - , "FROM income" - , "WHERE" - , "created_at >= :since" - , "OR edited_at >= :since" - , "OR deleted_at >= :since" - ]) - [ ":since" := since ] - ) - -create :: UserId -> Day -> Int -> Query () -create userId date amount = - Query (\conn -> do - createdAt <- getCurrentTime - SQLite.executeNamed - conn - "INSERT INTO income (user_id, date, amount, created_at) VALUES (:userId, :date, :amount, :createdAt)" - [ ":userId" := userId - , ":date" := date - , ":amount" := amount - , ":createdAt" := createdAt - ] - ) - -edit :: UserId -> IncomeId -> Day -> Int -> Query Bool -edit userId id date amount = - Query (\conn -> do - income <- fmap (\(Row i) -> i) . Maybe.listToMaybe <$> - SQLite.queryNamed conn "SELECT * FROM income WHERE id = :id" [ ":id" := id ] - if Maybe.isJust income then - do - currentTime <- getCurrentTime - SQLite.executeNamed - conn - "UPDATE income SET edited_at = :editedAt, date = :date, amount = :amount WHERE id = :id AND user_id = :userId" - [ ":editedAt" := currentTime - , ":date" := date - , ":amount" := amount - , ":id" := id - , ":userId" := userId - ] - return True - else - return False - ) - -delete :: UserId -> PaymentId -> Query () -delete userId id = - Query (\conn -> - SQLite.executeNamed - conn - "UPDATE income SET deleted_at = datetime('now') WHERE id = :id AND user_id = :userId" - [ ":id" := id - , ":userId" := userId - ] - ) - -data UserDayRow = UserDayRow (UserId, Day) - -instance FromRow UserDayRow where - fromRow = do - user <- SQLite.field - day <- SQLite.field - return $ UserDayRow (user, day) - -definedForAll :: [UserId] -> Query (Maybe Day) -definedForAll users = - Query (\conn -> - (fromRows . fmap (\(UserDayRow (user, day)) -> (user, day))) <$> - SQLite.query_ - conn - "SELECT user_id, MIN(date) FROM income WHERE deleted_at IS NULL GROUP BY user_id;" - ) - where - fromRows rows = - if L.sort users == L.sort (map fst rows) then - Maybe.listToMaybe . reverse . L.sort . map snd $ rows - else - Nothing - -getCumulativeIncome :: Day -> Day -> Query (Map UserId Int) -getCumulativeIncome start end = - Query (\conn -> M.fromList <$> SQLite.queryNamed conn (SQLite.Query query) parameters) - where - query = - T.intercalate "\n" $ - [ "SELECT user_id, CAST(ROUND(SUM(count)) AS INTEGER) FROM (" - , " SELECT" - , " I1.user_id," - , " ((JULIANDAY(MIN(I2.date)) - JULIANDAY(I1.date)) * I1.amount * 12 / 365) AS count" - , " FROM (" <> (selectBoundedIncomes ">" ":start") <> ") AS I1" - , " INNER JOIN (" <> (selectBoundedIncomes "<" ":end") <> ") AS I2" - , " ON I2.date > I1.date AND I2.user_id == I1.user_id" - , " GROUP BY I1.date, I1.user_id" - , ") GROUP BY user_id" - ] - - selectBoundedIncomes op param = - T.intercalate "\n" $ - [ " SELECT user_id, date, amount FROM (" - , " SELECT" - , " i.user_id, " <> param <> " AS date, i.amount" - , " FROM" - , " (SELECT id, MAX(date) AS max_date" - , " FROM income" - , " WHERE date <= " <> param <> " AND deleted_at IS NULL" - , " GROUP BY user_id) AS m" - , " INNER JOIN income AS i" - , " ON i.id = m.id AND i.date = m.max_date" - , " ) UNION" - , " SELECT user_id, date, amount" - , " FROM income" - , " WHERE date " <> op <> " " <> param <> " AND deleted_at IS NULL" - ] - - parameters = - [ ":start" := start - , ":end" := end - ] diff --git a/server/src/Persistence/Payment.hs b/server/src/Persistence/Payment.hs deleted file mode 100644 index 573d57f..0000000 --- a/server/src/Persistence/Payment.hs +++ /dev/null @@ -1,389 +0,0 @@ -module Persistence.Payment - ( count - , find - , getRange - , listAllPunctual - , listActivePage - , listModifiedPunctualSince - , listActiveMonthlyOrderedByName - , create - , createMany - , edit - , delete - , searchCategory - , repartition - , getPreAndPostPaymentRepartition - , usedCategories - ) where - -import Data.Map (Map) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Calendar (Day) -import qualified Data.Time.Calendar as Calendar -import Data.Time.Clock (UTCTime) -import Data.Time.Clock (getCurrentTime) -import Database.SQLite.Simple (FromRow (fromRow), - NamedParam ((:=)), ToRow) -import qualified Database.SQLite.Simple as SQLite -import Database.SQLite.Simple.ToField (ToField (toField)) -import Prelude hiding (id, until) - -import Common.Model (CategoryId, Frequency (..), - Payment (..), PaymentId, - User (..), UserId) -import qualified Common.Util.Text as TextUtil - -import Model.Query (Query (Query)) -import Persistence.Frequency (FrequencyField (..)) -import qualified Persistence.Income as IncomePersistence -import qualified Persistence.Util as PersistenceUtil - - -fields :: Text -fields = T.intercalate "," $ - [ "id" - , "user_id" - , "name" - , "cost" - , "date" - , "category" - , "frequency" - , "created_at" - , "edited_at" - , "deleted_at" - ] - -newtype Row = Row Payment - -instance FromRow Row where - fromRow = Row <$> (Payment <$> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - (fmap (\(FrequencyField f) -> f) $ SQLite.field) <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field) - -newtype InsertRow = InsertRow Payment - -instance ToRow InsertRow where - toRow (InsertRow p) = - [ toField (_payment_user p) - , toField (_payment_name p) - , toField (_payment_cost p) - , toField (_payment_date p) - , toField (_payment_category p) - , toField (FrequencyField (_payment_frequency p)) - , toField (_payment_createdAt p) - ] - -data Count = Count Int - -instance FromRow Count where - fromRow = Count <$> SQLite.field - -count :: Frequency -> Text -> Query Int -count frequency search = - Query (\conn -> - (\[Count n] -> n) <$> - SQLite.queryNamed - conn - (SQLite.Query $ T.intercalate " " - [ "SELECT COUNT(*)" - , "FROM payment" - , "WHERE" - , "deleted_at IS NULL" - , "AND frequency = :frequency" - , "AND (" <> PersistenceUtil.formatKeyForSearch "name" <> " LIKE :search OR cost LIKE :search)" - ]) - [ ":frequency" := FrequencyField frequency - , ":search" := "%" <> TextUtil.formatSearch search <> "%" - ] - ) - -find :: PaymentId -> Query (Maybe Payment) -find paymentId = - Query (\conn -> do - fmap (\(Row p) -> p) . Maybe.listToMaybe <$> - SQLite.queryNamed - conn - (SQLite.Query $ "SELECT " <> fields <> " FROM payment WHERE id = :id") - [ "id" := paymentId - ] - ) - -data RangeRow = RangeRow (Day, Day) - -instance FromRow RangeRow where - fromRow = (\f t -> RangeRow (f, t)) <$> SQLite.field <*> SQLite.field - -getRange :: Query (Maybe (Day, Day)) -getRange = - Query (\conn -> do - fmap (\(RangeRow (f, t)) -> (f, t)) . Maybe.listToMaybe <$> - SQLite.queryNamed - conn - (SQLite.Query $ T.intercalate " " - [ "SELECT MIN(date), MAX(date)" - , "FROM payment" - , "WHERE" - , "frequency = :frequency" - , "AND deleted_at IS NULL" - ]) - [ ":frequency" := FrequencyField Punctual - ] - ) - -listAllPunctual :: Query [Payment] -listAllPunctual = - Query (\conn -> - map (\(Row p) -> p) <$> - SQLite.queryNamed - conn - (SQLite.Query $ T.intercalate " " - [ "SELECT" - , fields - , "FROM payment" - , "WHERE deleted_at IS NULL AND frequency = :frequency" - , "ORDER BY date" - ]) - [ ":frequency" := FrequencyField Punctual - ] - ) - - -listActivePage :: Frequency -> Int -> Int -> Text -> Query [Payment] -listActivePage frequency page perPage search = - Query (\conn -> - map (\(Row p) -> p) <$> - SQLite.queryNamed - conn - (SQLite.Query $ T.intercalate " " - [ "SELECT" - , fields - , "FROM payment" - , "WHERE" - , "deleted_at IS NULL" - , "AND frequency = :frequency" - , "AND (" <> PersistenceUtil.formatKeyForSearch "name" <> " LIKE :search OR cost LIKE :search)" - , "ORDER BY date DESC" - , "LIMIT :limit" - , "OFFSET :offset" - ] - ) - [ ":frequency" := FrequencyField frequency - , ":search" := "%" <> TextUtil.formatSearch search <> "%" - , ":limit" := perPage - , ":offset" := (page - 1) * perPage - ] - ) - -listModifiedPunctualSince :: UTCTime -> Query [Payment] -listModifiedPunctualSince since = - Query (\conn -> - map (\(Row i) -> i) <$> - SQLite.queryNamed - conn - (SQLite.Query . T.intercalate " " $ - [ "SELECT " <> fields - , "FROM payment" - , "WHERE" - , "frequency = :frequency" - , "AND (created_at >= :since OR edited_at >= :since OR deleted_at >= :since)" - ]) - [ ":frequency" := FrequencyField Punctual - , ":since" := since - ] - ) - - -listActiveMonthlyOrderedByName :: Query [Payment] -listActiveMonthlyOrderedByName = - Query (\conn -> do - map (\(Row p) -> p) <$> - SQLite.queryNamed - conn - (SQLite.Query $ T.intercalate " " - [ "SELECT" - , fields - , "FROM payment" - , "WHERE deleted_at IS NULL AND frequency = :frequency" - , "ORDER BY name DESC" - ]) - [ ":frequency" := FrequencyField Monthly - ] - ) - -create :: UserId -> Text -> Int -> Day -> CategoryId -> Frequency -> Query () -create userId name cost date category frequency = - Query (\conn -> do - currentTime <- getCurrentTime - SQLite.executeNamed - conn - (SQLite.Query $ T.intercalate " " - [ "INSERT INTO payment (user_id, name, cost, date, category, frequency, created_at)" - , "VALUES (:userId, :name, :cost, :date, :category, :frequency, :currentTime)" - ]) - [ ":userId" := userId - , ":name" := name - , ":cost" := cost - , ":date" := date - , ":category" := category - , ":frequency" := FrequencyField frequency - , ":currentTime" := currentTime - ] - ) - -createMany :: [Payment] -> Query () -createMany payments = - Query (\conn -> - SQLite.executeMany - conn - (SQLite.Query $ T.intercalate "" - [ "INSERT INTO payment (user_id, name, cost, date, category, frequency, created_at)" - , "VALUES (?, ?, ?, ?, ?, ?, ?)" - ]) - (map InsertRow payments) - ) - -edit :: UserId -> PaymentId -> Text -> Int -> Day -> CategoryId -> Frequency -> Query Bool -edit userId paymentId name cost date category frequency = - Query (\conn -> do - payment <- fmap (\(Row p) -> p) . Maybe.listToMaybe <$> - SQLite.queryNamed - conn - (SQLite.Query $ - "SELECT " <> fields <> " FROM payment WHERE id = :paymentId and user_id = :userId") - [ ":paymentId" := paymentId - , ":userId" := userId - ] - if Maybe.isJust payment then - do - currentTime <- getCurrentTime - SQLite.executeNamed - conn - (SQLite.Query $ T.intercalate " " - [ "UPDATE" - , " payment" - , "SET" - , " edited_at = :editedAt," - , " name = :name," - , " cost = :cost," - , " date = :date," - , " category = :category," - , " frequency = :frequency" - , "WHERE" - , " id = :id" - , " AND user_id = :userId" - ]) - [ ":editedAt" := currentTime - , ":name" := name - , ":cost" := cost - , ":date" := date - , ":category" := category - , ":frequency" := FrequencyField frequency - , ":id" := paymentId - , ":userId" := userId - ] - return True - else - return False - ) - -delete :: UserId -> PaymentId -> Query () -delete userId paymentId = - Query (\conn -> - SQLite.executeNamed - conn - "UPDATE payment SET deleted_at = datetime('now') WHERE id = :id AND user_id = :userId" - [ ":id" := paymentId - , ":userId" := userId - ] - ) - -data CategoryIdRow = CategoryIdRow CategoryId - -instance FromRow CategoryIdRow where - fromRow = CategoryIdRow <$> SQLite.field - -searchCategory :: Text -> Query (Maybe CategoryId) -searchCategory paymentName = - Query (\conn -> - fmap (\(CategoryIdRow d) -> d) . Maybe.listToMaybe <$> - SQLite.queryNamed - conn - (SQLite.Query . T.intercalate " " $ - [ "SELECT category" - , "FROM payment" - , "WHERE deleted_at is NULL AND name LIKE :name" - , "ORDER BY edited_at, created_at" - , "LIMIT 1" - ]) - [ ":name" := "%" <> paymentName <> "%" - ] - ) - -usedCategories :: Query [CategoryId] -usedCategories = - Query (\conn -> do - map (\(CategoryIdRow p) -> p) <$> - SQLite.query_ - conn - (SQLite.Query $ T.intercalate " " - [ "SELECT DISTINCT category" - , "FROM payment" - , "WHERE deleted_at IS NULL" - ]) - ) - -data UserCostRow = UserCostRow (UserId, Int) - -instance FromRow UserCostRow where - fromRow = do - user <- SQLite.field - cost <- SQLite.field - return $ UserCostRow (user, cost) - -repartition :: Frequency -> Text -> Day -> Day -> Query (Map UserId Int) -repartition frequency search from to = - Query (\conn -> - M.fromList . fmap (\(UserCostRow r) -> r) <$> SQLite.queryNamed - conn - (SQLite.Query . T.intercalate " " $ - [ "SELECT user_id, SUM(cost)" - , "FROM payment" - , "WHERE" - , "deleted_at IS NULL" - , "AND frequency = :frequency" - , "AND (" <> PersistenceUtil.formatKeyForSearch "name" <> " LIKE :search OR cost LIKE :search)" - , "AND date >= :from" - , "AND date < :to" - , "GROUP BY user_id" - ]) - [ ":frequency" := FrequencyField frequency - , ":search" := "%" <> TextUtil.formatSearch search <> "%" - , ":from" := from - , ":to" := to - ] - ) - -getPreAndPostPaymentRepartition :: Maybe (Day, Day) -> [User] -> Query (Map UserId Int, Map UserId Int) -getPreAndPostPaymentRepartition paymentRange users = do - case paymentRange of - Just (from, to) -> do - incomeDefinedForAll <- IncomePersistence.definedForAll (_user_id <$> users) - (,) - <$> (repartition Punctual "" from (Maybe.fromMaybe (Calendar.addDays 1 to) incomeDefinedForAll)) - <*> (case incomeDefinedForAll of - Just d -> repartition Punctual "" d (Calendar.addDays 1 to) - Nothing -> return M.empty) - - Nothing -> - return (M.empty, M.empty) diff --git a/server/src/Persistence/User.hs b/server/src/Persistence/User.hs deleted file mode 100644 index 12145ac..0000000 --- a/server/src/Persistence/User.hs +++ /dev/null @@ -1,78 +0,0 @@ -module Persistence.User - ( list - , get - , checkPassword - , createSignInToken - ) where - -import qualified Data.Maybe as Maybe -import Data.Text (Text) -import Database.SQLite.Simple (FromRow (fromRow), NamedParam ((:=))) -import qualified Database.SQLite.Simple as SQLite - -import Common.Model (Email (..), Password (..), User (..)) - -import Model.HashedPassword (HashedPassword (..)) -import qualified Model.HashedPassword as HashedPassword -import Model.Query (Query (Query)) -import qualified Model.UUID as UUID - -newtype Row = Row User - -instance FromRow Row where - fromRow = Row <$> (User <$> - SQLite.field <*> - SQLite.field <*> - SQLite.field <*> - SQLite.field) - -list :: Query [User] -list = - Query (\conn -> do - map (\(Row u) -> u) <$> - SQLite.query_ conn "SELECT id, creation, email, name from user ORDER BY creation DESC" - ) - -get :: Text -> Query (Maybe User) -get token = - Query (\conn -> do - fmap (\(Row u) -> u) . Maybe.listToMaybe <$> - SQLite.queryNamed - conn - "SELECT id, creation, email, name FROM user WHERE sign_in_token = :sign_in_token LIMIT 1" - [ ":sign_in_token" := token ] - ) - -data HashedPasswordRow = HashedPasswordRow HashedPassword - -instance FromRow HashedPasswordRow where - fromRow = HashedPasswordRow <$> (HashedPassword <$> SQLite.field) - -checkPassword :: Email -> Password -> Query Bool -checkPassword (Email email) password = - Query (\conn -> do - hashedPassword <- fmap (\(HashedPasswordRow p) -> p) . Maybe.listToMaybe <$> - SQLite.queryNamed - conn - "SELECT password FROM user WHERE email = :email LIMIT 1" - [ ":email" := email ] - case hashedPassword of - Just h -> - return (HashedPassword.check password h) - - Nothing -> - return False - ) - -createSignInToken :: Email -> Query Text -createSignInToken (Email email) = - Query (\conn -> do - token <- UUID.generateUUID - SQLite.executeNamed - conn - "UPDATE user SET sign_in_token = :sign_in_token WHERE email = :email" - [ ":sign_in_token" := token - , ":email" := email - ] - return token - ) diff --git a/server/src/Persistence/Util.hs b/server/src/Persistence/Util.hs deleted file mode 100644 index b7496c6..0000000 --- a/server/src/Persistence/Util.hs +++ /dev/null @@ -1,11 +0,0 @@ -module Persistence.Util - ( formatKeyForSearch - ) where - -import Data.Text (Text) - -formatKeyForSearch :: Text -> Text -formatKeyForSearch key = - "replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower(" - <> key - <> "), 'à', 'a'), 'â', 'a'), 'ç', 'c'), 'è', 'e'), 'é', 'e'), 'ê', 'e'), 'ë', 'e'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ù', 'u'), 'û', 'u'), 'ü', 'u')" diff --git a/server/src/Resource.hs b/server/src/Resource.hs deleted file mode 100644 index a12a0f2..0000000 --- a/server/src/Resource.hs +++ /dev/null @@ -1,54 +0,0 @@ -module Resource - ( Resource - , resourceCreatedAt - , resourceEditedAt - , resourceDeletedAt - , Status(..) - , statuses - , groupByStatus - , statusDuring - ) where - -import Data.Map (Map) -import qualified Data.Map as M -import Data.Maybe (fromMaybe) -import Data.Time.Clock (UTCTime) - -class Resource a where - resourceCreatedAt :: a -> UTCTime - resourceEditedAt :: a -> Maybe UTCTime - resourceDeletedAt :: a -> Maybe UTCTime - -data Status = - Created - | Edited - | Deleted - deriving (Eq, Show, Read, Ord, Enum, Bounded) - -statuses :: [Status] -statuses = [minBound..] - -groupByStatus :: Resource a => UTCTime -> UTCTime -> [a] -> Map Status [a] -groupByStatus start end resources = - foldl - (\m resource -> - case statusDuring start end resource of - Just status -> M.insertWith (++) status [resource] m - Nothing -> m - ) - M.empty - resources - -statusDuring :: Resource a => UTCTime -> UTCTime -> a -> Maybe Status -statusDuring start end resource - | created && not deleted = Just Created - | not created && edited && not deleted = Just Edited - | not created && deleted = Just Deleted - | otherwise = Nothing - where - created = belongs (resourceCreatedAt resource) start end - edited = fromMaybe False (fmap (\t -> belongs t start end) $ resourceEditedAt resource) - deleted = fromMaybe False (fmap (\t -> belongs t start end) $ resourceDeletedAt resource) - -belongs :: UTCTime -> UTCTime -> UTCTime -> Bool -belongs time start end = time >= start && time < end diff --git a/server/src/Secure.hs b/server/src/Secure.hs deleted file mode 100644 index a30941f..0000000 --- a/server/src/Secure.hs +++ /dev/null @@ -1,31 +0,0 @@ -module Secure - ( loggedAction - ) where - -import Control.Monad.IO.Class (liftIO) -import qualified Data.Text.Lazy as TL -import qualified Network.HTTP.Types.Status as HTTP -import Web.Scotty - -import Common.Model (User) -import qualified Common.Msg as Msg - -import qualified LoginSession -import qualified Model.Query as Query -import qualified Persistence.User as UserPersistence - -loggedAction :: (User -> ActionM ()) -> ActionM () -loggedAction action = do - maybeToken <- LoginSession.get - case maybeToken of - Just token -> do - maybeUser <- liftIO . Query.run . UserPersistence.get $ token - case maybeUser of - Just user -> - action user - Nothing -> do - status HTTP.forbidden403 - html . TL.fromStrict . Msg.get $ Msg.Secure_Unauthorized - Nothing -> do - status HTTP.forbidden403 - html . TL.fromStrict . Msg.get $ Msg.Secure_Forbidden diff --git a/server/src/SendMail.hs b/server/src/SendMail.hs deleted file mode 100644 index 13d4072..0000000 --- a/server/src/SendMail.hs +++ /dev/null @@ -1,66 +0,0 @@ -module SendMail - ( sendMail - ) where - -import Control.Arrow (left) -import Control.Exception (SomeException, try) -import Data.Either (isLeft) -import qualified Network.Mail.Mime as M - -import Data.Text (Text) -import qualified Data.Text as T -import qualified Data.Text.IO as T -import qualified Data.Text.Lazy as LT -import Data.Text.Lazy.Builder (fromText, toLazyText) - -import Conf (Conf) -import qualified Conf -import Model.Mail (Mail (..)) - -sendMail :: Conf -> Mail -> IO (Either Text ()) -sendMail conf mail = - if Conf.devMode conf - then - do - T.putStrLn . mockMailMessage $ mail - return (Right ()) - else - do - result <- left (T.pack . show) <$> (try (M.renderSendMail . getMimeMail $ mail) :: IO (Either SomeException ())) - if isLeft result - then putStrLn ("Error sending the following email:" ++ (show mail) ++ "\n" ++ (show result)) - else return () - return result - -mockMailMessage :: Mail -> Text -mockMailMessage mail = T.concat $ - [ "[MOCK MAIL] " - , subject mail - , " (from: " - , from mail - , ") (to: " - , T.intercalate ", " $ to mail - , ")" - , "\n" - , body mail - , "\n" - ] - -getMimeMail :: Mail -> M.Mail -getMimeMail (Mail mailFrom mailTo mailSubject mailPlainBody) = - let fromMail = M.emptyMail (address mailFrom) - in fromMail - { M.mailTo = map address mailTo - , M.mailParts = [ [ M.plainPart . strictToLazy $ mailPlainBody ] ] - , M.mailHeaders = [("Subject", mailSubject)] - } - -address :: Text -> M.Address -address addressEmail = - M.Address - { M.addressName = Nothing - , M.addressEmail = addressEmail - } - -strictToLazy :: Text -> LT.Text -strictToLazy = toLazyText . fromText diff --git a/server/src/Statistics.hs b/server/src/Statistics.hs deleted file mode 100644 index e463aac..0000000 --- a/server/src/Statistics.hs +++ /dev/null @@ -1,59 +0,0 @@ -module Statistics - ( paymentsAndIncomes - ) where - -import Control.Arrow ((&&&)) -import qualified Data.List as L -import Data.Map (Map) -import qualified Data.Map as M -import qualified Data.Maybe as Maybe -import qualified Data.Time.Calendar as Calendar - -import Common.Model (Income (..), MonthStats (..), Payment (..), - Stats) - -paymentsAndIncomes :: [Payment] -> [Income] -> Stats -paymentsAndIncomes payments incomes = - - map toMonthStat . M.toList $ foldl - (\m p -> M.alter (alter p) (startOfMonth $ _payment_date p) m) - M.empty - payments - - where - - toMonthStat (start, paymentsByCategory) = - MonthStats start paymentsByCategory (incomesAt start) - - incomesAt day = - M.map (incomeAt day) lastToFirstIncomesByUser - - incomeAt day lastToFirstIncome = - Maybe.maybe 0 _income_amount - . Maybe.listToMaybe - . dropWhile (\i -> _income_date i > day) - $ lastToFirstIncome - - lastToFirstIncomesByUser = - M.map (reverse . L.sortOn _income_date) - . groupBy _income_userId - $ incomes - - initMonthStats = - M.fromList - . map (\category -> (category, 0)) - . L.nub - $ map _payment_category payments - - alter p Nothing = Just (addPayment p initMonthStats) - alter p (Just monthStats) = Just (addPayment p monthStats) - - addPayment p monthStats = M.adjust ((+) (_payment_cost p)) (_payment_category p) monthStats - - startOfMonth day = - let (y, m, _) = Calendar.toGregorian day - in Calendar.fromGregorian y m 1 - -groupBy :: Ord k => (a -> k) -> [a] -> Map k [a] -groupBy key = - M.fromListWith (++) . map (key &&& pure) diff --git a/server/src/Util/Time.hs b/server/src/Util/Time.hs deleted file mode 100644 index 4a29fcc..0000000 --- a/server/src/Util/Time.hs +++ /dev/null @@ -1,22 +0,0 @@ -module Util.Time - ( belongToCurrentMonth - , belongToCurrentWeek - ) where - -import Data.Time.Calendar (toGregorian) -import Data.Time.Calendar.WeekDate (toWeekDate) -import Data.Time.Clock (UTCTime, getCurrentTime) - -import qualified Common.Util.Time as Time - -belongToCurrentMonth :: UTCTime -> IO Bool -belongToCurrentMonth time = do - (timeYear, timeMonth, _) <- toGregorian <$> Time.timeToDay time - (actualYear, actualMonth, _) <- toGregorian <$> (getCurrentTime >>= Time.timeToDay) - return (actualYear == timeYear && actualMonth == timeMonth) - -belongToCurrentWeek :: UTCTime -> IO Bool -belongToCurrentWeek time = do - (timeYear, timeWeek, _) <- toWeekDate <$> Time.timeToDay time - (actualYear, actualWeek, _) <- toWeekDate <$> (getCurrentTime >>= Time.timeToDay) - return (actualYear == timeYear && actualWeek == timeWeek) diff --git a/server/src/Validation/Category.hs b/server/src/Validation/Category.hs deleted file mode 100644 index 12f2117..0000000 --- a/server/src/Validation/Category.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Validation.Category - ( createCategory - , editCategory - ) where - -import Data.Text (Text) -import Data.Validation (Validation) -import qualified Data.Validation as V - -import Common.Model (CreateCategoryForm (..), - EditCategoryForm (..)) -import qualified Common.Validation.Category as CategoryValidation -import Model.CreateCategory (CreateCategory (..)) -import Model.EditCategory (EditCategory (..)) - -createCategory :: CreateCategoryForm -> Validation Text CreateCategory -createCategory form = - CreateCategory - <$> CategoryValidation.name (_createCategoryForm_name form) - <*> CategoryValidation.color (_createCategoryForm_color form) - -editCategory :: EditCategoryForm -> Validation Text EditCategory -editCategory form = - EditCategory - <$> V.Success (_editCategoryForm_id form) - <*> CategoryValidation.name (_editCategoryForm_name form) - <*> CategoryValidation.color (_editCategoryForm_color form) diff --git a/server/src/Validation/Income.hs b/server/src/Validation/Income.hs deleted file mode 100644 index 5e034d1..0000000 --- a/server/src/Validation/Income.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Validation.Income - ( createIncome - , editIncome - ) where - -import Data.Text (Text) -import Data.Validation (Validation) -import qualified Data.Validation as V - -import Common.Model (CreateIncomeForm (..), - EditIncomeForm (..)) -import qualified Common.Validation.Income as IncomeValidation -import Model.CreateIncome (CreateIncome (..)) -import Model.EditIncome (EditIncome (..)) - -createIncome :: CreateIncomeForm -> Validation Text CreateIncome -createIncome form = - CreateIncome - <$> IncomeValidation.amount (_createIncomeForm_amount form) - <*> IncomeValidation.date (_createIncomeForm_date form) - -editIncome :: EditIncomeForm -> Validation Text EditIncome -editIncome form = - EditIncome - <$> V.Success (_editIncomeForm_id form) - <*> IncomeValidation.amount (_editIncomeForm_amount form) - <*> IncomeValidation.date (_editIncomeForm_date form) diff --git a/server/src/Validation/Payment.hs b/server/src/Validation/Payment.hs deleted file mode 100644 index 20e370e..0000000 --- a/server/src/Validation/Payment.hs +++ /dev/null @@ -1,33 +0,0 @@ -module Validation.Payment - ( createPayment - , editPayment - ) where - -import Data.Text (Text) -import Data.Validation (Validation) -import qualified Data.Validation as V - -import Common.Model (CategoryId, CreatePaymentForm (..), - EditPaymentForm (..)) -import qualified Common.Validation.Payment as PaymentValidation -import Model.CreatePayment (CreatePayment (..)) -import Model.EditPayment (EditPayment (..)) - -createPayment :: [CategoryId] -> CreatePaymentForm -> Validation Text CreatePayment -createPayment categories form = - CreatePayment - <$> PaymentValidation.name (_createPaymentForm_name form) - <*> PaymentValidation.cost (_createPaymentForm_cost form) - <*> PaymentValidation.date (_createPaymentForm_date form) - <*> PaymentValidation.category categories (_createPaymentForm_category form) - <*> V.Success (_createPaymentForm_frequency form) - -editPayment :: [CategoryId] -> EditPaymentForm -> Validation Text EditPayment -editPayment categories form = - EditPayment - <$> V.Success (_editPaymentForm_id form) - <*> PaymentValidation.name (_editPaymentForm_name form) - <*> PaymentValidation.cost (_editPaymentForm_cost form) - <*> PaymentValidation.date (_editPaymentForm_date form) - <*> PaymentValidation.category categories (_editPaymentForm_category form) - <*> V.Success (_editPaymentForm_frequency form) diff --git a/server/src/Validation/SignIn.hs b/server/src/Validation/SignIn.hs deleted file mode 100644 index dc86122..0000000 --- a/server/src/Validation/SignIn.hs +++ /dev/null @@ -1,16 +0,0 @@ -module Validation.SignIn - ( signIn - ) where - -import Data.Text (Text) -import Data.Validation (Validation) - -import Common.Model (SignInForm (..)) -import qualified Common.Validation.SignIn as SignInValidation -import Model.SignIn (SignIn (..)) - -signIn :: SignInForm -> Validation Text SignIn -signIn form = - SignIn - <$> SignInValidation.email (_signInForm_email form) - <*> SignInValidation.password (_signInForm_password form) diff --git a/server/src/View/Mail/WeeklyReport.hs b/server/src/View/Mail/WeeklyReport.hs deleted file mode 100644 index 3fe224f..0000000 --- a/server/src/View/Mail/WeeklyReport.hs +++ /dev/null @@ -1,124 +0,0 @@ -module View.Mail.WeeklyReport - ( mail - ) where - -import Data.List (sortOn) -import Data.Map (Map) -import qualified Data.Map as M -import Data.Maybe (catMaybes, fromMaybe) -import Data.Monoid ((<>)) -import Data.Text (Text) -import qualified Data.Text as T -import Data.Time.Clock (UTCTime) - -import Common.Model (ExceedingPayer (..), Income (..), - Payment (..), User (..), UserId) -import qualified Common.Model as CM -import qualified Common.Msg as Msg -import qualified Common.View.Format as Format - -import Conf (Conf) -import qualified Conf as Conf -import Model.IncomeResource (IncomeResource (..)) -import Model.Mail (Mail (Mail)) -import qualified Model.Mail as M -import Model.PaymentResource (PaymentResource (..)) -import qualified Payer as Payer -import Resource (Status (..), groupByStatus, statuses) - -mail :: Conf -> [User] -> [Income] -> [Payment] -> Map UserId Int -> Map UserId Int -> Map UserId Int -> UTCTime -> UTCTime -> Mail -mail conf users weekIncomes weekPayments cumulativeIncome preIncomeRepartition postIncomeRepartition start end = - Mail - { M.from = Conf.noReplyMail conf - , M.to = map _user_email users - , M.subject = T.concat - [ Msg.get Msg.App_Title - , " − " - , Msg.get Msg.WeeklyReport_Title - ] - , M.body = body conf users weekIncomes weekPayments cumulativeIncome preIncomeRepartition postIncomeRepartition start end - } - -body :: Conf -> [User] -> [Income] -> [Payment] -> Map UserId Int -> Map UserId Int -> Map UserId Int -> UTCTime -> UTCTime -> Text -body conf users weekIncomes weekPayments cumulativeIncome preIncomeRepartition postIncomeRepartition start end = - T.intercalate "\n" $ - [ exceedingPayers conf users cumulativeIncome preIncomeRepartition postIncomeRepartition - , operations conf users paymentsGroupedByStatus incomesGroupedByStatus - ] - where - paymentsGroupedByStatus = groupByStatus start end . map PaymentResource $ weekPayments - incomesGroupedByStatus = groupByStatus start end . map IncomeResource $ weekIncomes - -exceedingPayers :: Conf -> [User] -> Map UserId Int -> Map UserId Int -> Map UserId Int -> Text -exceedingPayers conf users cumulativeIncome preIncomeRepartition postIncomeRepartition = - T.intercalate "\n" . map formatPayer $ payers - where - payers = Payer.getExceedingPayers users cumulativeIncome preIncomeRepartition postIncomeRepartition - formatPayer p = T.concat - [ " * " - , fromMaybe "" $ _user_name <$> CM.findUser (_exceedingPayer_userId p) users - , " + " - , Format.price (Conf.currency conf) $ _exceedingPayer_amount p - , "\n" - ] - -operations :: Conf -> [User] -> Map Status [PaymentResource] -> Map Status [IncomeResource] -> Text -operations conf users paymentsByStatus incomesByStatus = - if M.null paymentsByStatus && M.null incomesByStatus - then - Msg.get Msg.WeeklyReport_Empty - else - T.intercalate "\n" . catMaybes . concat $ - [ map (\s -> paymentSection s conf users <$> M.lookup s paymentsByStatus) statuses - , map (\s -> incomeSection s conf users <$> M.lookup s incomesByStatus) statuses - ] - -paymentSection :: Status -> Conf -> [User] -> [PaymentResource] -> Text -paymentSection status conf users payments = - section sectionTitle sectionItems - where count = length payments - sectionTitle = Msg.get $ case status of - Created -> if count > 1 then Msg.WeeklyReport_PaymentsCreated count else Msg.WeeklyReport_PaymentCreated count - Edited -> if count > 1 then Msg.WeeklyReport_PaymentsEdited count else Msg.WeeklyReport_PaymentEdited count - Deleted -> if count > 1 then Msg.WeeklyReport_PaymentsDeleted count else Msg.WeeklyReport_PaymentDeleted count - sectionItems = map (payedFor status conf users) . sortOn _payment_date . map (\(PaymentResource p) -> p) $ payments - -payedFor :: Status -> Conf -> [User] -> Payment -> Text -payedFor status conf users payment = - case status of - Deleted -> Msg.get (Msg.WeeklyReport_PayedForNot name amount for at) - _ -> Msg.get (Msg.WeeklyReport_PayedFor name amount for at) - where name = formatUserName (_payment_user payment) users - amount = Format.price (Conf.currency conf) . _payment_cost $ payment - for = _payment_name payment - at = Format.longDay $ _payment_date payment - -incomeSection :: Status -> Conf -> [User] -> [IncomeResource] -> Text -incomeSection status conf users incomes = - section sectionTitle sectionItems - where count = length incomes - sectionTitle = Msg.get $ case status of - Created -> if count > 1 then Msg.WeeklyReport_IncomesCreated count else Msg.WeeklyReport_IncomeCreated count - Edited -> if count > 1 then Msg.WeeklyReport_IncomesEdited count else Msg.WeeklyReport_IncomeEdited count - Deleted -> if count > 1 then Msg.WeeklyReport_IncomesDeleted count else Msg.WeeklyReport_IncomeDeleted count - sectionItems = map (isPayedFrom status conf users) . sortOn _income_date . map (\(IncomeResource i) -> i) $ incomes - -isPayedFrom :: Status -> Conf -> [User] -> Income -> Text -isPayedFrom status conf users income = - case status of - Deleted -> Msg.get (Msg.WeeklyReport_PayedFromNot name amount for) - _ -> Msg.get (Msg.WeeklyReport_PayedFrom name amount for) - where name = formatUserName (_income_userId income) users - amount = Format.price (Conf.currency conf) . _income_amount $ income - for = Format.longDay $ _income_date income - -formatUserName :: UserId -> [User] -> Text -formatUserName userId = fromMaybe "−" . fmap _user_name . CM.findUser userId - -section :: Text -> [Text] -> Text -section title items = - T.concat - [ title - , "\n\n" - , T.unlines . map (" * " <>) $ items - ] diff --git a/server/src/View/Page.hs b/server/src/View/Page.hs deleted file mode 100644 index ae7a266..0000000 --- a/server/src/View/Page.hs +++ /dev/null @@ -1,43 +0,0 @@ -module View.Page - ( page - ) where - -import Data.Aeson (encode) -import qualified Data.Aeson.Types as Json -import Data.Text.Internal.Lazy (Text) -import Data.Text.Lazy.Encoding (decodeUtf8) -import Prelude hiding (init) - -import Text.Blaze.Html -import Text.Blaze.Html.Renderer.Text (renderHtml) -import Text.Blaze.Html5 -import qualified Text.Blaze.Html5 as H -import Text.Blaze.Html5.Attributes -import qualified Text.Blaze.Html5.Attributes as A - -import Common.Model (Init) -import qualified Common.Msg as Msg - -page :: Maybe Init -> Text -page init = - renderHtml . docTypeHtml $ do - H.head $ do - meta ! charset "UTF-8" - meta ! name "viewport" ! content "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" - H.title (toHtml $ Msg.get Msg.App_Title) - script ! src "/javascript/main.js" $ "" - script ! src "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.js" $ "" - jsonScript "init" init - link ! rel "stylesheet" ! type_ "text/css" ! href "/css/reset.css" - link ! rel "stylesheet" ! type_ "text/css" ! href "/css/main.css" - link ! rel "icon" ! type_ "image/png" ! href "/images/icon.png" - H.body $ do - H.div ! A.class_ "spinner" $ "" - - -jsonScript :: Json.ToJSON a => Text -> a -> Html -jsonScript scriptId json = - script - ! A.id (toValue scriptId) - ! type_ "application/json" - $ toHtml . decodeUtf8 . encode $ json diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..796a8df --- /dev/null +++ b/shell.nix @@ -0,0 +1,30 @@ +with (import (builtins.fetchGit { + name = "nixpkgs-20.09"; + url = "git@github.com:nixos/nixpkgs.git"; + rev = "cd63096d6d887d689543a0b97743d28995bc9bc3"; + ref = "refs/tags/20.09"; +}){}); + +let nixpkgs-mozilla = fetchFromGitHub { + owner = "mozilla"; + repo = "nixpkgs-mozilla"; + # commit from 2020-10-28 + rev = "8c007b60731c07dd7a052cce508de3bb1ae849b4"; + sha256 = "1zybp62zz0h077zm2zmqs2wcg3whg6jqaah9hcl1gv4x8af4zhs6"; +}; in + +with import "${nixpkgs-mozilla.out}/rust-overlay.nix" pkgs pkgs; + +pkgs.mkShell { + + buildInputs = [ + # rustChannels.nightly.rust + ((rustChannelOf { channel = "1.49.0"; }).rust) + cargo-watch + lld + openssl + pkgconfig + sqlite + ]; + +} diff --git a/sql/fixtures.sql b/sql/fixtures.sql new file mode 100644 index 0000000..61ff934 --- /dev/null +++ b/sql/fixtures.sql @@ -0,0 +1,45 @@ +INSERT INTO + users(email, name, password) +VALUES + ('john@mail.com', 'John', '$2b$10$Qy8lqrTqHdzwLZwsqvO09eMwehA.vti.AGwPVj/pZYL94Ni6zozT2'), + ('lisa@mail.com', 'Lisa', '$2b$10$Qy8lqrTqHdzwLZwsqvO09eMwehA.vti.AGwPVj/pZYL94Ni6zozT2'); + +INSERT INTO + incomes(user_id, date, amount) +VALUES + (1, '2020-01-01', 1500), + (2, '2020-01-01', 2000); + +INSERT INTO + categories(name, color) +VALUES + ('Habitation', '#aa0000'), + ('Alimentation', '#00aa00'), + ('Animaux', '#0000aa'); + +INSERT INTO + payments(user_id, name, cost, date, frequency, category_id) +VALUES + (1, 'Loyer', 600, '2021-01-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-02-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-03-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-04-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-05-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-06-01', 'Punctual', 1), + (1, 'Loyer', 600, '2020-07-01', 'Punctual', 1), + (1, 'Loyer', 600, '2020-08-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-09-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-10-01', 'Punctual', 1), + (2, 'Loyer', 600, '2020-11-01', 'Punctual', 1), + (1, 'Loyer', 600, '2020-12-01', 'Punctual', 1), + (1, 'Loyer', 600, '2020-01-01', 'Punctual', 1), + (1, 'Marché', 55, '2021-01-02', 'Punctual', 2), + (2, 'Restaurant', 60, '2020-12-10', 'Punctual', 2), + (1, 'Vétérinaire', 105, '2020-11-18', 'Punctual', 3), + (1, 'Magasin', 150, '2020-10-29', 'Punctual', 2); + +INSERT INTO + jobs(name) +VALUES + ('MonthlyPayment'), + ('WeeklyReport'); diff --git a/sql/migrations/1.sql b/sql/migrations/1.sql new file mode 100644 index 0000000..d7c300e --- /dev/null +++ b/sql/migrations/1.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS "user" ( + "id" INTEGER PRIMARY KEY, + "creation" TIMESTAMP NOT NULL, + "email" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL, + CONSTRAINT "uniq_user_email" UNIQUE ("email"), + CONSTRAINT "uniq_user_name" UNIQUE ("name") +); + +CREATE TABLE IF NOT EXISTS "job" ( + "id" INTEGER PRIMARY KEY, + "kind" VARCHAR NOT NULL, + "last_execution" TIMESTAMP NULL, + "last_check" TIMESTAMP NULL, + CONSTRAINT "uniq_job_kind" UNIQUE ("kind") +); + +CREATE TABLE IF NOT EXISTS "sign_in"( + "id" INTEGER PRIMARY KEY, + "token" VARCHAR NOT NULL, + "creation" TIMESTAMP NOT NULL, + "email" VARCHAR NOT NULL, + "is_used" BOOLEAN NOT NULL, + CONSTRAINT "uniq_sign_in_token" UNIQUE ("token") +); + +CREATE TABLE IF NOT EXISTS "payment"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "user", + "name" VARCHAR NOT NULL, + "cost" INTEGER NOT NULL, + "date" DATE NOT NULL, + "frequency" VARCHAR NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + "deleted_at" TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS "income"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "user", + "date" DATE NOT NULL, + "amount" INTEGERNOT NULL, + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + "deleted_at" TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS "category"( + "id" INTEGER PRIMARY KEY, + "name" VARCHAR NOT NULL, + "color" VARCHAR NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + "deleted_at" TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS "payment_category"( + "id" INTEGER PRIMARY KEY, + "name" VARCHAR NOT NULL, + "category" INTEGER NOT NULL REFERENCES "category", + "created_at" TIMESTAMP NOT NULL, + "edited_at" TIMESTAMP NULL, + CONSTRAINT "uniq_payment_category_name" UNIQUE ("name") +); diff --git a/sql/migrations/2.sql b/sql/migrations/2.sql new file mode 100644 index 0000000..c1d502f --- /dev/null +++ b/sql/migrations/2.sql @@ -0,0 +1,44 @@ +-- Add payment categories with accents from payment with accents + +INSERT INTO + payment_category (name, category, created_at) +SELECT + DISTINCT lower(payment.name), payment_category.category, datetime('now') +FROM + payment +INNER JOIN + payment_category +ON + replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower(payment.name), 'é', 'e'), 'è', 'e'), 'à', 'a'), 'û', 'u'), 'â', 'a'), 'ê', 'e'), 'â', 'a'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ë', 'e') = payment_category.name +WHERE + payment.name +IN + (SELECT DISTINCT payment.name FROM payment WHERE lower(payment.name) NOT IN (SELECT payment_category.name FROM payment_category) AND payment.deleted_at IS NULL); + +-- Remove unused payment categories + +DELETE FROM + payment_category +WHERE + name NOT IN (SELECT DISTINCT lower(name) FROM payment); + +-- Add category id to payment table + +PRAGMA foreign_keys = 0; + +ALTER TABLE payment ADD COLUMN "category" INTEGER NOT NULL REFERENCES "category" DEFAULT -1; + +PRAGMA foreign_keys = 1; + +UPDATE + payment +SET + category = (SELECT category FROM payment_category WHERE payment_category.name = LOWER(payment.name)) +WHERE + EXISTS (SELECT category FROM payment_category WHERE payment_category.name = LOWER(payment.name)); + +DELETE FROM payment WHERE category = -1; + +-- Remove + +DROP TABLE payment_category; diff --git a/sql/migrations/3.sql b/sql/migrations/3.sql new file mode 100644 index 0000000..a3d8a13 --- /dev/null +++ b/sql/migrations/3.sql @@ -0,0 +1,5 @@ +DROP TABLE sign_in; + +ALTER TABLE user ADD COLUMN "password" TEXT NOT NULL DEFAULT "password"; + +ALTER TABLE user ADD COLUMN "sign_in_token" TEXT NULL; diff --git a/sql/migrations/4.sql b/sql/migrations/4.sql new file mode 100644 index 0000000..ec386cb --- /dev/null +++ b/sql/migrations/4.sql @@ -0,0 +1,91 @@ +-- Payments + +CREATE TABLE IF NOT EXISTS "payments"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "users", + "name" TEXT NOT NULL, + "cost" INTEGER NOT NULL, + "date" DATE NOT NULL, + "frequency" TEXT NOT NULL, + "category_id" INTEGER NOT NULL REFERENCES "categories", + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL +); + +INSERT INTO payments (id, user_id, name, cost, date, frequency, category_id, created_at, updated_at, deleted_at) + SELECT id, user_id, name, cost, date, frequency, category, created_at, edited_at, deleted_at + FROM payment; + +DROP TABLE payment; + +CREATE INDEX payment_date ON payments(date); + +-- Categories + +CREATE TABLE IF NOT EXISTS "categories"( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL, + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL +); + +INSERT INTO categories (id, name, color, created_at, updated_at, deleted_at) + SELECT id, name, color, created_at, edited_at, deleted_at + FROM category; + +DROP TABLE category; + +-- Users + +CREATE TABLE IF NOT EXISTS "users"( + "id" INTEGER PRIMARY KEY, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "password" TEXT NOT NULL, + "login_token" TEXT NULL, + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL, + CONSTRAINT "uniq_user_email" UNIQUE ("email"), + CONSTRAINT "uniq_user_name" UNIQUE ("name") +); + +INSERT INTO users (id, created_at, email, name, password, login_token) + SELECT id, creation, email, name, password, sign_in_token + FROM user; + +DROP TABLE user; + +-- Jobs + +CREATE TABLE IF NOT EXISTS "jobs"( + "name" TEXT PRIMARY KEY, + "last_execution" DATE NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO jobs (name, last_execution) + SELECT kind, last_execution + FROM job; + +DROP TABLE job; + +-- Incomes + +CREATE TABLE IF NOT EXISTS "incomes"( + "id" INTEGER PRIMARY KEY, + "user_id" INTEGER NOT NULL REFERENCES "users", + "date" DATE NOT NULL, + "amount" INTEGER NOT NULL, + "created_at" DATE NULL DEFAULT (datetime('now')), + "updated_at" DATE NULL, + "deleted_at" DATE NULL +); + +INSERT INTO incomes (id, user_id, date, amount, created_at, updated_at, deleted_at) + SELECT id, user_id, date, amount, created_at, edited_at, deleted_at + FROM income; + +DROP TABLE income; diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..dc46c78 --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,27 @@ +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::iter::FromIterator; + +pub fn get() -> HashMap { + let paths = fs::read_dir("assets").unwrap().map(|e| { + let path = format!("{}", e.unwrap().path().display()); + let file = fs::read(&path).unwrap(); + let mut path_iter = path.split("/"); + path_iter.next(); + let name = path_iter.collect::>().join("/"); + let hashed = format!("/assets/{}/{}", sha256(file), name); + (name, hashed) + }); + HashMap::from_iter(paths) +} + +fn sha256(input: Vec) -> String { + let mut hasher = Sha256::new(); + hasher.update(input); + hasher + .finalize() + .iter() + .map(|b| format!("{:x}", b)) + .collect() +} diff --git a/src/controller/balance.rs b/src/controller/balance.rs new file mode 100644 index 0000000..228ff04 --- /dev/null +++ b/src/controller/balance.rs @@ -0,0 +1,71 @@ +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::model::user::User; +use crate::payer; +use crate::templates; + +pub async fn get(wallet: &Wallet) -> Response { + let users = db::users::list(&wallet.pool).await; + + let incomes_from = db::incomes::defined_for_all(&wallet.pool).await; + let user_incomes = match incomes_from { + Some(from) => db::incomes::cumulative(&wallet.pool, from).await, + None => HashMap::new(), + }; + let template_user_incomes = + get_template_user_incomes(&users, &user_incomes); + let total_income: i64 = user_incomes.values().sum(); + + let user_payments = db::payments::repartition(&wallet.pool).await; + let template_user_payments = + get_template_user_payments(&users, &user_payments); + let total_payments: i64 = + user_payments.clone().into_iter().map(|p| p.1).sum(); + + let exceeding_payers = + payer::exceeding(&users, &user_incomes, &user_payments); + + let mut context = Context::new(); + context.insert("header", &templates::Header::Balance); + context.insert("connected_user", &wallet.user); + context.insert( + "incomes_from", + &incomes_from.map(|d| d.format("%d/%m/%Y").to_string()), + ); + context.insert("total_income", &total_income); + context.insert("user_incomes", &template_user_incomes); + context.insert("total_payments", &total_payments); + context.insert("user_payments", &template_user_payments); + context.insert("exceeding_payers", &exceeding_payers); + + utils::template(&wallet.assets, &wallet.templates, "balance.html", context) +} + +fn get_template_user_payments( + users: &Vec, + user_payments: &HashMap, +) -> Vec<(String, i64)> { + let mut user_payments: Vec<(String, i64)> = users + .into_iter() + .map(|u| (u.name.clone(), *user_payments.get(&u.id).unwrap_or(&0))) + .collect(); + user_payments.sort_by_key(|i| i.1); + user_payments +} + +fn get_template_user_incomes( + users: &Vec, + user_incomes: &HashMap, +) -> Vec<(String, i64)> { + let mut user_incomes: Vec<(String, i64)> = users + .into_iter() + .map(|u| (u.name.clone(), *user_incomes.get(&u.id).unwrap_or(&0))) + .collect(); + user_incomes.sort_by_key(|i| i.1); + user_incomes +} diff --git a/src/controller/categories.rs b/src/controller/categories.rs new file mode 100644 index 0000000..b1a3664 --- /dev/null +++ b/src/controller/categories.rs @@ -0,0 +1,141 @@ +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::queries; +use crate::templates; +use crate::validation; + +pub async fn table( + wallet: &Wallet, + query: queries::Categories, +) -> Response { + let categories = db::categories::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Categories); + context.insert("connected_user", &wallet.user); + context.insert("categories", &categories); + context.insert("highlight", &query.highlight); + + utils::template( + &wallet.assets, + &wallet.templates, + "category/table.html", + context, + ) +} + +pub async fn create_form(wallet: &Wallet) -> Response { + create_form_feedback(wallet, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + form: HashMap, + error: Option, +) -> Response { + let mut context = Context::new(); + context.insert("header", &templates::Header::Categories); + context.insert("connected_user", &wallet.user.clone()); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "category/create.html", + context, + ) +} + +pub async fn create( + wallet: &Wallet, + form: HashMap, +) -> Response { + let error = |e: &str| { + create_form_feedback(wallet, form.clone(), Some(e.to_string())) + }; + + match validation::category::create(&form) { + Some(category) => { + match db::categories::create(&wallet.pool, &category).await { + Some(id) => { + utils::redirect(&format!("/categories?highlight={}", id)) + } + None => error("Erreur serveur").await, + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form(id: i64, wallet: &Wallet) -> Response { + update_form_feedback(id, wallet, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + form: HashMap, + error: Option, +) -> Response { + let category = db::categories::get(&wallet.pool, id).await; + let is_category_used = + db::payments::is_category_used(&wallet.pool, id).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Categories); + context.insert("connected_user", &wallet.user); + context.insert("id", &id); + context.insert("category", &category); + context.insert("is_category_used", &is_category_used); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "category/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + form: HashMap, +) -> Response { + let error = |e: &str| { + update_form_feedback(id, wallet, form.clone(), Some(e.to_string())) + }; + + match validation::category::update(&form) { + Some(update_category) => { + if db::categories::update(&wallet.pool, id, &update_category).await + { + utils::redirect(&format!("/categories?highlight={}", id)) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete(id: i64, wallet: &Wallet) -> Response { + if db::categories::delete(&wallet.pool, id).await { + utils::redirect("/categories") + } else { + update_form_feedback( + id, + wallet, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} diff --git a/src/controller/error.rs b/src/controller/error.rs new file mode 100644 index 0000000..8dad16b --- /dev/null +++ b/src/controller/error.rs @@ -0,0 +1,31 @@ +use hyper::header::CACHE_CONTROL; +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::{Context, Tera}; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; + +pub fn error(wallet: &Wallet, title: &str, message: &str) -> Response { + utils::with_header( + Response::new( + template(&wallet.assets, &wallet.templates, title, message).into(), + ), + CACHE_CONTROL, + "no-cache", + ) +} + +pub fn template( + assets: &HashMap, + templates: &Tera, + title: &str, + message: &str, +) -> String { + let mut context = Context::new(); + context.insert("title", title); + context.insert("message", message); + context.insert("assets", assets); + + templates.render("error.html", &context).unwrap() +} diff --git a/src/controller/incomes.rs b/src/controller/incomes.rs new file mode 100644 index 0000000..ea7f1cf --- /dev/null +++ b/src/controller/incomes.rs @@ -0,0 +1,221 @@ +use chrono::Datelike; +use chrono::Utc; +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::queries; +use crate::templates; +use crate::validation; + +static PER_PAGE: i64 = 10; + +pub async fn table(wallet: &Wallet, query: queries::Incomes) -> Response { + let page = query.page.unwrap_or(1); + let count = db::incomes::count(&wallet.pool).await; + let incomes = db::incomes::list(&wallet.pool, page, PER_PAGE).await; + let max_page = (count as f32 / PER_PAGE as f32).ceil() as i64; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Incomes); + context.insert("connected_user", &wallet.user); + context.insert("incomes", &incomes); + context.insert("page", &page); + context.insert("max_page", &max_page); + context.insert("highlight", &query.highlight); + + utils::template( + &wallet.assets, + &wallet.templates, + "income/table.html", + context, + ) +} + +static MONTHS: [&str; 12] = [ + "Janvier", + "Février", + "Mars", + "Avril", + "Mai", + "Juin", + "Juillet", + "Août", + "Septembre", + "Octobre", + "Novembre", + "Décembre", +]; + +pub async fn create_form( + wallet: &Wallet, + query: queries::Incomes, +) -> Response { + create_form_feedback(wallet, query, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, + error: Option, +) -> Response { + let users = db::users::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Incomes); + context.insert("connected_user", &wallet.user); + context.insert("users", &users); + context.insert("query", &query); + context.insert("current_month", &Utc::today().naive_utc().month()); + context.insert("months", &MONTHS); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "income/create.html", + context, + ) +} + +pub async fn create( + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, +) -> Response { + let error = |e: &str| { + create_form_feedback(wallet, query, form.clone(), Some(e.to_string())) + }; + + match validation::income::create(&form) { + Some(income) => { + if !db::incomes::defined_at( + &wallet.pool, + income.user_id, + income.month, + income.year, + ) + .await + .is_empty() + { + error("Un revenu est déjà défini à cette date.").await + } else { + match db::incomes::create(&wallet.pool, &income).await { + Some(id) => { + let row = db::incomes::get_row(&wallet.pool, id).await; + let page = (row - 1) / PER_PAGE + 1; + utils::redirect(&format!( + "/incomes?page={}&highlight={}", + page, id + )) + } + None => error("Erreur serveur").await, + } + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form( + id: i64, + wallet: &Wallet, + query: queries::Incomes, +) -> Response { + update_form_feedback(id, wallet, query, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, + error: Option, +) -> Response { + let users = db::users::list(&wallet.pool).await; + let income = db::incomes::get(&wallet.pool, id).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Incomes); + context.insert("connected_user", &wallet.user); + context.insert("users", &users); + context.insert("id", &id); + context.insert("income", &income); + context.insert("query", &query); + context.insert("months", &MONTHS); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "income/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + query: queries::Incomes, + form: HashMap, +) -> Response { + let error = |e: &str| { + update_form_feedback( + id, + wallet, + query, + form.clone(), + Some(e.to_string()), + ) + }; + + match validation::income::update(&form) { + Some(income) => { + let existing_incomes = db::incomes::defined_at( + &wallet.pool, + income.user_id, + income.month, + income.year, + ) + .await; + if existing_incomes.into_iter().any(|eid| eid != id) { + error("Un revenu est déjà défini à cette date.").await + } else if db::incomes::update(&wallet.pool, id, &income).await { + let row = db::incomes::get_row(&wallet.pool, id).await; + let page = (row - 1) / PER_PAGE + 1; + utils::redirect(&format!( + "/incomes?page={}&highlight={}", + page, id + )) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete( + id: i64, + wallet: &Wallet, + query: queries::Incomes, +) -> Response { + if db::incomes::delete(&wallet.pool, id).await { + utils::redirect(&format!("/incomes?page={}", query.page.unwrap_or(1))) + } else { + update_form_feedback( + id, + wallet, + query, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} diff --git a/src/controller/login.rs b/src/controller/login.rs new file mode 100644 index 0000000..ea9db57 --- /dev/null +++ b/src/controller/login.rs @@ -0,0 +1,86 @@ +use bcrypt; +use hyper::{Body, Response}; +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use tera::{Context, Tera}; +use uuid::Uuid; + +use crate::controller::wallet::Wallet; +use crate::controller::{error, utils}; +use crate::db; +use crate::model::config::Config; +use crate::model::user::User; +use crate::validation; + +pub async fn page( + assets: &HashMap, + templates: &Tera, + error: Option, +) -> Response { + let connected_user: Option = None; + + let mut context = Context::new(); + context.insert("connected_user", &connected_user); + context.insert("error", &error); + + utils::template(assets, templates, "login.html", context) +} + +pub async fn login( + config: Config, + assets: &HashMap, + templates: &Tera, + form: HashMap, + pool: SqlitePool, +) -> Response { + let not_authorized = page( + assets, + templates, + Some("Vous n’êtes pas autorisé à vous connecter.".to_string()), + ) + .await; + let server_error = + page(assets, templates, Some("Erreur serveur.".to_string())).await; + match validation::login::login(&form) { + Some(login) => { + match db::users::get_password_hash(&pool, login.email.clone()).await + { + Some(hash) => match bcrypt::verify(login.password, &hash) { + Ok(true) => { + let login_token = Uuid::new_v4(); + if db::users::set_login_token( + &pool, + login.email, + login_token.clone().to_string(), + ) + .await + { + utils::with_login_cookie( + config, + login_token, + utils::redirect("/"), + ) + } else { + server_error + } + } + Ok(false) => not_authorized, + Err(err) => { + error!("Error verifying bcrypt password: {:?}", err); + server_error + } + }, + None => not_authorized, + } + } + None => not_authorized, + } +} + +pub async fn logout(config: Config, wallet: &Wallet) -> Response { + if db::users::remove_login_token(&wallet.pool, wallet.user.id).await { + utils::with_logout_cookie(config, utils::redirect("/")) + } else { + error::error(&wallet, "Erreur serveur", "Erreur serveur") + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..e2ef561 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,9 @@ +pub mod balance; +pub mod categories; +pub mod error; +pub mod incomes; +pub mod login; +pub mod payments; +pub mod statistics; +pub mod utils; +pub mod wallet; diff --git a/src/controller/payments.rs b/src/controller/payments.rs new file mode 100644 index 0000000..ab4bd92 --- /dev/null +++ b/src/controller/payments.rs @@ -0,0 +1,227 @@ +use hyper::header::CONTENT_TYPE; +use hyper::{Body, Response}; +use std::collections::HashMap; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::model::frequency::Frequency; +use crate::queries; +use crate::templates; +use crate::validation; + +static PER_PAGE: i64 = 10; + +pub async fn table( + wallet: &Wallet, + query: queries::Payments, +) -> Response { + let page = query.page.unwrap_or(1); + let count = db::payments::count(&wallet.pool, &query).await; + let payments = + db::payments::list_for_table(&wallet.pool, &query, PER_PAGE).await; + let max_page = (count.count as f32 / PER_PAGE as f32).ceil() as i64; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Payments); + context.insert("connected_user", &wallet.user); + context.insert("payments", &payments); + context.insert("page", &page); + context.insert("max_page", &max_page); + context.insert("query", &query); + context.insert("count", &count.count); + context.insert("total_cost", &count.total_cost); + + utils::template( + &wallet.assets, + &wallet.templates, + "payment/table.html", + context, + ) +} + +pub async fn create_form( + wallet: &Wallet, + query: queries::Payments, +) -> Response { + create_form_feedback(wallet, query, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + query: queries::Payments, + form: HashMap, + error: Option, +) -> Response { + let users = db::users::list(&wallet.pool).await; + let categories = db::categories::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Payments); + context.insert("connected_user", &wallet.user); + context.insert("users", &users); + context.insert("categories", &categories); + context.insert("query", &query); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "payment/create.html", + context, + ) +} + +pub async fn create( + wallet: &Wallet, + query: queries::Payments, + form: HashMap, +) -> Response { + let error = |e: &str| { + create_form_feedback(wallet, query, form.clone(), Some(e.to_string())) + }; + + match validation::payment::create(&form) { + Some(create_payment) => { + match db::payments::create(&wallet.pool, &create_payment).await { + Some(id) => { + let row = db::payments::get_row( + &wallet.pool, + id, + create_payment.frequency, + ) + .await; + let page = (row - 1) / PER_PAGE + 1; + let query = queries::Payments { + page: Some(page), + search: None, + frequency: Some(create_payment.frequency), + highlight: Some(id), + }; + utils::redirect(&format!( + "/{}", + queries::payments_url(query) + )) + } + None => error("Erreur serveur.").await, + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form( + id: i64, + wallet: &Wallet, + query: queries::Payments, +) -> Response { + update_form_feedback(id, wallet, query, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + query: queries::Payments, + form: HashMap, + error: Option, +) -> Response { + let payment = db::payments::get_for_form(&wallet.pool, id).await; + let users = db::users::list(&wallet.pool).await; + let categories = db::categories::list(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Payments); + context.insert("connected_user", &wallet.user); + context.insert("id", &id); + context.insert("payment", &payment); + context.insert("users", &users); + context.insert("categories", &categories); + context.insert("query", &query); + context.insert("form", &form); + context.insert("error", &error); + + utils::template( + &wallet.assets, + &wallet.templates, + "payment/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + query: queries::Payments, + form: HashMap, +) -> Response { + let error = |e: &str| { + update_form_feedback( + id, + wallet, + query.clone(), + form.clone(), + Some(e.to_string()), + ) + }; + + match validation::payment::update(&form) { + Some(update_payment) => { + if db::payments::update(&wallet.pool, id, &update_payment).await { + let frequency = query.frequency.unwrap_or(Frequency::Punctual); + let row = + db::payments::get_row(&wallet.pool, id, frequency).await; + let page = (row - 1) / PER_PAGE + 1; + let query = queries::Payments { + page: Some(page), + search: None, + frequency: Some(frequency), + highlight: Some(id), + }; + utils::redirect(&format!("/{}", queries::payments_url(query))) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete( + id: i64, + wallet: &Wallet, + query: queries::Payments, +) -> Response { + if db::payments::delete(&wallet.pool, id).await { + let query = queries::Payments { + highlight: None, + ..query + }; + utils::redirect(&format!("/{}", queries::payments_url(query))) + } else { + update_form_feedback( + id, + wallet, + query, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} + +pub async fn search_category( + wallet: &Wallet, + query: queries::PaymentCategory, +) -> Response { + match db::payments::search_category(&wallet.pool, query.payment_name).await + { + Some(category_id) => utils::with_header( + Response::new(format!("{}", category_id).into()), + CONTENT_TYPE, + "application/json", + ), + None => utils::not_found(), + } +} diff --git a/src/controller/statistics.rs b/src/controller/statistics.rs new file mode 100644 index 0000000..38a5787 --- /dev/null +++ b/src/controller/statistics.rs @@ -0,0 +1,30 @@ +use hyper::{Body, Response}; +use tera::Context; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::templates; + +pub async fn get(wallet: &Wallet) -> Response { + let categories = db::categories::list(&wallet.pool).await; + let payments = db::payments::list_for_stats(&wallet.pool).await; + let incomes = db::incomes::total_each_month(&wallet.pool).await; + + let mut context = Context::new(); + context.insert("header", &templates::Header::Statistics); + context.insert("connected_user", &wallet.user); + context.insert( + "json_categories", + &serde_json::to_string(&categories).unwrap(), + ); + context.insert("json_payments", &serde_json::to_string(&payments).unwrap()); + context.insert("json_incomes", &serde_json::to_string(&incomes).unwrap()); + + utils::template( + &wallet.assets, + &wallet.templates, + "statistics.html", + context, + ) +} diff --git a/src/controller/utils.rs b/src/controller/utils.rs new file mode 100644 index 0000000..225f8a4 --- /dev/null +++ b/src/controller/utils.rs @@ -0,0 +1,119 @@ +use hyper::header::{ + HeaderName, HeaderValue, CACHE_CONTROL, CONTENT_TYPE, LOCATION, SET_COOKIE, +}; +use hyper::{Body, Response, StatusCode}; +use std::collections::HashMap; +use tera::{Context, Tera}; +use tokio::fs::File; +use tokio_util::codec::{BytesCodec, FramedRead}; +use uuid::Uuid; + +use crate::controller::error; +use crate::model::config::Config; + +pub fn with_header( + response: Response, + name: HeaderName, + value: &str, +) -> Response { + with_headers(response, vec![(name, value)]) +} + +pub fn with_headers( + response: Response, + headers: Vec<(HeaderName, &str)>, +) -> Response { + let mut response = response; + let response_headers = response.headers_mut(); + for (name, value) in headers { + response_headers.insert(name, HeaderValue::from_str(value).unwrap()); + } + response +} + +pub fn with_login_cookie( + config: Config, + login_token: Uuid, + response: Response, +) -> Response { + let cookie = format!( + "TOKEN={}; SameSite=Strict; HttpOnly; Max-Age=86400{}", + login_token, + if config.secure_cookies { + "; Secure" + } else { + "" + } + ); + + with_header(response, SET_COOKIE, &cookie) +} + +pub fn with_logout_cookie( + config: Config, + response: Response, +) -> Response { + let cookie = format!( + "TOKEN=; SameSite=Strict; HttpOnly; Max-Age=0{}", + if config.secure_cookies { + "; Secure" + } else { + "" + } + ); + + with_header(response, SET_COOKIE, &cookie) +} + +pub fn template( + assets: &HashMap, + templates: &Tera, + path: &str, + context: Context, +) -> Response { + let mut context = context.clone(); + context.insert("assets", assets); + + let response = match templates.render(path, &context) { + Ok(template) => Response::new(template.into()), + Err(err) => Response::new( + error::template( + assets, + templates, + "Erreur serveur", + &format!( + "Erreur lors de la préparation de la page : {:?}", + err + ), + ) + .into(), + ), + }; + + with_headers( + response, + vec![(CONTENT_TYPE, "text/html"), (CACHE_CONTROL, "no-cache")], + ) +} + +pub fn redirect(uri: &str) -> Response { + let mut response = Response::default(); + *response.status_mut() = StatusCode::MOVED_PERMANENTLY; + with_headers(response, vec![(LOCATION, uri), (CACHE_CONTROL, "no-cache")]) +} + +pub fn not_found() -> Response { + let mut response = Response::default(); + *response.status_mut() = StatusCode::NOT_FOUND; + response +} + +pub async fn file(filename: &str) -> Response { + if let Ok(file) = File::open(filename).await { + let stream = FramedRead::new(file, BytesCodec::new()); + let body = Body::wrap_stream(stream); + with_header(Response::new(body), CACHE_CONTROL, "max-age=3153600000") + } else { + not_found() + } +} diff --git a/src/controller/wallet.rs b/src/controller/wallet.rs new file mode 100644 index 0000000..2a4a593 --- /dev/null +++ b/src/controller/wallet.rs @@ -0,0 +1,13 @@ +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use tera::Tera; + +use crate::model::user::User; + +#[derive(Clone)] +pub struct Wallet { + pub pool: SqlitePool, + pub assets: HashMap, + pub templates: Tera, + pub user: User, +} diff --git a/src/db/categories.rs b/src/db/categories.rs new file mode 100644 index 0000000..05b1323 --- /dev/null +++ b/src/db/categories.rs @@ -0,0 +1,132 @@ +use sqlx::sqlite::SqlitePool; + +use crate::model::category::{Category, Create, Update}; + +pub async fn list(pool: &SqlitePool) -> Vec { + let res = sqlx::query_as::<_, Category>( + r#" +SELECT + id, + name, + color +FROM + categories +WHERE + deleted_at IS NULL +ORDER BY + name + "#, + ) + .fetch_all(pool) + .await; + + match res { + Ok(categories) => categories, + Err(err) => { + error!("Error listing categories: {:?}", err); + vec![] + } + } +} + +pub async fn get(pool: &SqlitePool, id: i64) -> Option { + let query = r#" +SELECT + id, + name, + color +FROM + categories +WHERE + id = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query_as::<_, Category>(query) + .bind(id) + .fetch_one(pool) + .await; + + match res { + Ok(p) => Some(p), + Err(err) => { + error!("Error looking for category {}: {:?}", id, err); + None + } + } +} + +pub async fn create(pool: &SqlitePool, c: &Create) -> Option { + let res = sqlx::query( + r#" +INSERT INTO + categories(name, color, created_at) +VALUES + (?, ?, datetime()) + "#, + ) + .bind(c.name.clone()) + .bind(c.color.clone()) + .execute(pool) + .await; + + match res { + Ok(x) => Some(x.last_insert_rowid()), + Err(err) => { + error!("Error creating category: {:?}", err); + None + } + } +} + +pub async fn update(pool: &SqlitePool, id: i64, c: &Update) -> bool { + let res = sqlx::query( + r#" +UPDATE + categories +SET + name = ?, + color = ?, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(c.name.clone()) + .bind(c.color.clone()) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating category {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + categories +SET + deleted_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error deleting category {}: {:?}", id, err); + false + } + } +} diff --git a/src/db/incomes.rs b/src/db/incomes.rs new file mode 100644 index 0000000..cbbfce7 --- /dev/null +++ b/src/db/incomes.rs @@ -0,0 +1,494 @@ +use chrono::NaiveDate; +use sqlx::error::Error; +use sqlx::sqlite::{SqlitePool, SqliteRow}; +use sqlx_core::row::Row; +use std::collections::HashMap; +use std::iter::FromIterator; + +use crate::model::income::{Create, Form, Stat, Table, Update}; +use crate::model::report::Report; + +pub async fn count(pool: &SqlitePool) -> i64 { + let query = r#" +SELECT + COUNT(*) AS count +FROM + incomes +WHERE + incomes.deleted_at IS NULL + "#; + + let res = sqlx::query(&query) + .map(|row: SqliteRow| row.get("count")) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error counting incomes: {:?}", err); + 0 + } + } +} + +pub async fn list(pool: &SqlitePool, page: i64, per_page: i64) -> Vec { + let query = r#" +SELECT + incomes.id, + users.name AS user, + strftime('%m/%Y', incomes.date) AS date, + incomes.amount +FROM + incomes +INNER JOIN + users +ON + incomes.user_id = users.id +WHERE + incomes.deleted_at IS NULL +ORDER BY + incomes.date DESC +LIMIT ? +OFFSET ? + "#; + + let res = sqlx::query_as::<_, Table>(query) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(pool) + .await; + + match res { + Ok(incomes) => incomes, + Err(err) => { + error!("Error listing incomes: {:?}", err); + vec![] + } + } +} + +pub async fn get_row(pool: &SqlitePool, id: i64) -> i64 { + let query = r#" +SELECT + row +FROM ( + SELECT + ROW_NUMBER () OVER (ORDER BY date DESC) AS row, + id + FROM + incomes + WHERE + deleted_at IS NULL +) +WHERE + id = ? + "#; + + let res = sqlx::query(query) + .bind(id) + .map(|row: SqliteRow| row.get("row")) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error getting income row: {:?}", err); + 1 + } + } +} + +pub async fn get(pool: &SqlitePool, id: i64) -> Option { + let query = r#" +SELECT + id, + amount, + user_id, + CAST(strftime('%m', date) AS INTEGER) as month, + CAST(strftime('%Y', date) AS INTEGER) as year +FROM + incomes +WHERE + id = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query_as::<_, Form>(query) + .bind(id) + .fetch_one(pool) + .await; + + match res { + Ok(p) => Some(p), + Err(err) => { + error!("Error looking for income {}: {:?}", id, err); + None + } + } +} + +pub async fn create(pool: &SqlitePool, i: &Create) -> Option { + let res = sqlx::query( + r#" +INSERT INTO + incomes(user_id, date, amount, created_at) +VALUES + (?, ?, ?, datetime()) + "#, + ) + .bind(i.user_id) + .bind(NaiveDate::from_ymd(i.year, i.month, 1)) + .bind(i.amount) + .execute(pool) + .await; + + match res { + Ok(x) => Some(x.last_insert_rowid()), + Err(err) => { + error!("Error creating income: {:?}", err); + None + } + } +} + +pub async fn defined_at( + pool: &SqlitePool, + user_id: i64, + month: u32, + year: i32, +) -> Vec { + let query = r#" +SELECT + id +FROM + incomes +WHERE + user_id = ? + AND date = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query(&query) + .bind(user_id) + .bind(NaiveDate::from_ymd(year, month, 1)) + .map(|row: SqliteRow| row.get("id")) + .fetch_all(pool) + .await; + + match res { + Ok(ids) => ids, + Err(Error::RowNotFound) => vec![], + Err(err) => { + error!("Error looking if income is defined: {:?}", err); + vec![] + } + } +} + +pub async fn update(pool: &SqlitePool, id: i64, i: &Update) -> bool { + let res = sqlx::query( + r#" +UPDATE + incomes +SET + user_id = ?, + date = ?, + amount = ?, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(i.user_id) + .bind(NaiveDate::from_ymd(i.year, i.month, 1)) + .bind(i.amount) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating income {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + incomes +SET + deleted_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error deleting income {}: {:?}", id, err); + false + } + } +} + +pub async fn defined_for_all(pool: &SqlitePool) -> Option { + let res = sqlx::query( + r#" +SELECT + (CASE COUNT(users.id) == COUNT(min_income.date) + WHEN 1 THEN MIN(min_income.date) + ELSE NULL + END) AS date +FROM + users +LEFT OUTER JOIN ( + SELECT + user_id, + MIN(date) AS date + FROM + incomes + WHERE + deleted_at IS NULL + GROUP BY + user_id +) min_income +ON + users.id = min_income.user_id; + "#, + ) + .map(|row: SqliteRow| row.get("date")) + .fetch_one(pool) + .await; + + match res { + Ok(d) => d, + Err(err) => { + error!("Error looking for incomes defined for all: {:?}", err); + None + } + } +} + +pub async fn cumulative( + pool: &SqlitePool, + from: NaiveDate, +) -> HashMap { + let res = sqlx::query(&cumulative_query(from)) + .map(|row: SqliteRow| (row.get("user_id"), row.get("income"))) + .fetch_all(pool) + .await; + + match res { + Ok(incomes) => HashMap::from_iter(incomes), + Err(err) => { + error!("Error computing cumulative income: {:?}", err); + HashMap::new() + } + } +} + +/// Select cumulative income of users from the given date and until now. +/// +/// Associate each month income to its start and end bounds, +/// then compute the total income of each period, +/// sum it to get the final result. +/// +/// Considering each month to be 365 / 12 days long. +fn cumulative_query(from: NaiveDate) -> String { + format!( + r#" +SELECT + users.id AS user_id, + COALESCE(incomes.income, 0) AS income +FROM + users +LEFT OUTER JOIN ( + SELECT + user_id, + CAST(ROUND(SUM(count)) AS INTEGER) AS income + FROM ( + SELECT + I1.user_id, + ((JULIANDAY(MIN(I2.date)) - JULIANDAY(I1.date)) * I1.amount * 12 / 365) AS count + FROM + ({}) AS I1 + INNER JOIN + ({}) AS I2 + ON + I2.date > I1.date + AND I2.user_id == I1.user_id + GROUP BY + I1.date, I1.user_id + ) + GROUP BY + user_id +) incomes +ON + users.id = incomes.user_id + "#, + bounded_query(">".to_string(), from.format("%Y-%m-%d").to_string()), + bounded_query("<".to_string(), "date()".to_string()) + ) +} + +/// Select bounded incomes to the operator and date. +/// +/// It filters incomes according to the operator and date, +/// and adds the income at this date. +fn bounded_query(op: String, date: String) -> String { + format!( + r#" +SELECT + user_id, + date, + amount +FROM ( + SELECT + user_id, + {} AS date, + amount, + MAX(date) AS max_date + FROM + incomes + WHERE + date <= {} + AND deleted_at IS NULL + GROUP BY + user_id +) UNION +SELECT + user_id, + date, + amount +FROM + incomes +WHERE + date {} {} + AND deleted_at IS NULL + "#, + date, date, op, date + ) +} + +/// Select total income each month. +/// +/// For each month, from the first defined income and until now, +/// compute the total income of the users. +pub async fn total_each_month(pool: &SqlitePool) -> Vec { + let query = r#" +WITH RECURSIVE dates(date) AS ( + VALUES(( + SELECT + strftime('%Y-%m-01', MIN(date)) + FROM + incomes + WHERE + deleted_at IS NULL + )) + UNION ALL + SELECT + date(date, '+1 month') + FROM + dates + WHERE + date < date(date(), '-1 month') +) +SELECT + strftime('%Y-%m-01', dates.date) AS date, + ( + SELECT + SUM(amount) AS amount + FROM ( + SELECT ( + SELECT + amount + FROM + incomes + WHERE + user_id = users.id + AND date < date(dates.date, '+1 month') + AND deleted_at IS NULL + ORDER BY + date DESC + LIMIT + 1 + ) AS amount + FROM + users + ) + ) AS amount +FROM + dates; + "#; + + let res = sqlx::query_as::<_, Stat>(query).fetch_all(pool).await; + + match res { + Ok(xs) => xs, + Err(err) => { + error!("Error listing incomes for statistics: {:?}", err); + vec![] + } + } +} + +pub async fn last_week(pool: &SqlitePool) -> Vec { + let query = r#" +SELECT + strftime('%m/%Y', incomes.date) AS date, + users.name AS name, + incomes.amount AS amount, + (CASE + WHEN + incomes.deleted_at IS NOT NULL + THEN + 'Deleted' + WHEN + incomes.updated_at IS NOT NULL + AND incomes.created_at < date('now', 'weekday 0', '-13 days') + THEN + 'Updated' + ELSE + 'Created' + END) AS action +FROM + incomes +INNER JOIN + users +ON + incomes.user_id = users.id +WHERE + ( + incomes.created_at >= date('now', 'weekday 0', '-13 days') + AND incomes.created_at < date('now', 'weekday 0', '-6 days') + ) OR ( + incomes.updated_at >= date('now', 'weekday 0', '-13 days') + AND incomes.updated_at < date('now', 'weekday 0', '-6 days') + ) OR ( + incomes.deleted_at >= date('now', 'weekday 0', '-13 days') + AND incomes.deleted_at < date('now', 'weekday 0', '-6 days') + ) +ORDER BY + incomes.date + "#; + + let res = sqlx::query_as::<_, Report>(query).fetch_all(pool).await; + + match res { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments for report: {:?}", err); + vec![] + } + } +} diff --git a/src/db/jobs.rs b/src/db/jobs.rs new file mode 100644 index 0000000..88c2005 --- /dev/null +++ b/src/db/jobs.rs @@ -0,0 +1,56 @@ +use sqlx::error::Error; +use sqlx::sqlite::SqlitePool; + +use crate::model::job::Job; + +pub async fn should_run(pool: &SqlitePool, job: Job) -> bool { + let run_from = match job { + Job::WeeklyReport => "date('now', 'weekday 0', '-6 days')", + Job::MonthlyPayment => "date('now', 'start of month')", + }; + + let query = format!( + r#" +SELECT + 1 +FROM + jobs +WHERE + name = ? + AND last_execution < {} + "#, + run_from + ); + + let res = sqlx::query(&query).bind(job).fetch_one(pool).await; + + match res { + Ok(_) => true, + Err(Error::RowNotFound) => false, + Err(err) => { + error!("Error looking if job should run: {:?}", err); + false + } + } +} + +pub async fn actualize_last_execution(pool: &SqlitePool, job: Job) -> () { + let query = r#" +UPDATE + jobs +SET + last_execution = datetime() +WHERE + name = ? + "#; + + let res = sqlx::query(query).bind(job).execute(pool).await; + + match res { + Ok(_) => (), + Err(err) => { + error!("Error actualizing job last execution: {:?}", err); + () + } + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..a0aa3dc --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,6 @@ +pub mod categories; +pub mod incomes; +pub mod jobs; +pub mod payments; +pub mod users; +mod utils; diff --git a/src/db/payments.rs b/src/db/payments.rs new file mode 100644 index 0000000..0197375 --- /dev/null +++ b/src/db/payments.rs @@ -0,0 +1,525 @@ +use sqlx::error::Error; +use sqlx::sqlite::{Sqlite, SqliteArguments}; +use sqlx::sqlite::{SqlitePool, SqliteRow}; +use sqlx::FromRow; +use sqlx_core::row::Row; +use std::collections::HashMap; +use std::iter::FromIterator; + +use crate::db::utils; +use crate::model::frequency::Frequency; +use crate::model::payment; +use crate::model::report::Report; +use crate::queries; +use crate::utils::text; + +#[derive(FromRow)] +pub struct Count { + pub count: i64, + pub total_cost: i64, +} + +pub async fn count( + pool: &SqlitePool, + payment_query: &queries::Payments, +) -> Count { + let search = payment_query.search.clone().unwrap_or("".to_string()); + + let query = format!( + r#" +SELECT + COUNT(*) AS count, + SUM(payments.cost) AS total_cost +FROM + payments +INNER JOIN + users ON users.id = payments.user_id +INNER JOIN + categories ON categories.id = payments.category_id +WHERE + payments.deleted_at IS NULL + AND payments.frequency = ? + {} + "#, + search_query(search.clone()) + ); + + let res = bind_search( + sqlx::query_as::<_, Count>(&query) + .bind(payment_query.frequency.unwrap_or(Frequency::Punctual)), + search, + ) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error counting payments: {:?}", err); + Count { + count: 0, + total_cost: 0, + } + } + } +} + +pub async fn list_for_table( + pool: &SqlitePool, + payment_query: &queries::Payments, + per_page: i64, +) -> Vec { + let offset = (payment_query.page.unwrap_or(1) - 1) * per_page; + let search = payment_query.search.clone().unwrap_or("".to_string()); + + let query = format!( + r#" +SELECT + payments.id, + payments.name, + payments.cost, + users.name AS user, + categories.name AS category_name, + categories.color AS category_color, + strftime('%d/%m/%Y', date) AS date, + payments.frequency AS frequency +FROM + payments +INNER JOIN + users ON users.id = payments.user_id +INNER JOIN + categories ON categories.id = payments.category_id +WHERE + payments.deleted_at IS NULL + AND payments.frequency = ? + {} +ORDER BY + payments.date DESC +LIMIT ? +OFFSET ? + "#, + search_query(search.clone()) + ); + + let res = bind_search( + sqlx::query_as::<_, payment::Table>(&query) + .bind(payment_query.frequency.unwrap_or(Frequency::Punctual)), + search, + ) + .bind(per_page) + .bind(offset) + .fetch_all(pool) + .await; + + match res { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments: {:?}", err); + vec![] + } + } +} + +fn search_query(search: String) -> String { + let payments_name = utils::format_key_for_search("payments.name"); + let users_name = utils::format_key_for_search("users.name"); + let categories_name = utils::format_key_for_search("categories.name"); + + search + .split_ascii_whitespace() + .map(|_| { + format!( + r#" +AND ( + {} LIKE ? + OR payments.cost LIKE ? + OR {} LIKE ? + OR {} LIKE ? + OR strftime('%d/%m/%Y', date) LIKE ? +) + "#, + payments_name, users_name, categories_name + ) + }) + .collect::>() + .join(" ") +} + +fn bind_search<'a, Row: FromRow<'a, SqliteRow>>( + query: sqlx::query::QueryAs<'a, Sqlite, Row, SqliteArguments<'a>>, + search: String, +) -> sqlx::query::QueryAs<'a, Sqlite, Row, SqliteArguments<'a>> { + search.split_ascii_whitespace().fold(query, |q, word| { + let s = format!("%{}%", text::format_search(&word.to_string())); + q.bind(s.clone()) + .bind(s.clone()) + .bind(s.clone()) + .bind(s.clone()) + .bind(s.clone()) + }) +} + +pub async fn list_for_stats(pool: &SqlitePool) -> Vec { + let query = r#" +SELECT + strftime('%Y-%m-01', payments.date) AS start_date, + SUM(payments.cost) AS cost, + payments.category_id AS category_id +FROM + payments +WHERE + payments.deleted_at IS NULL + AND payments.frequency = 'Punctual' +GROUP BY + start_date, + payments.category_id; + "#; + + let result = sqlx::query_as::<_, payment::Stat>(query) + .fetch_all(pool) + .await; + + match result { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments for statistics: {:?}", err); + vec![] + } + } +} + +pub async fn get_row(pool: &SqlitePool, id: i64, frequency: Frequency) -> i64 { + let query = r#" +SELECT + row +FROM ( + SELECT + ROW_NUMBER () OVER (ORDER BY date DESC) AS row, + id + FROM + payments + WHERE + deleted_at IS NULL + AND frequency = ? +) +WHERE + id = ? + "#; + + let res = sqlx::query(query) + .bind(frequency) + .bind(id) + .map(|row: SqliteRow| row.get("row")) + .fetch_one(pool) + .await; + + match res { + Ok(count) => count, + Err(err) => { + error!("Error getting payment row: {:?}", err); + 1 + } + } +} + +pub async fn get_for_form(pool: &SqlitePool, id: i64) -> Option { + let query = r#" +SELECT + id, + name, + cost, + user_id, + category_id, + strftime('%Y-%m-%d', date) AS date, + frequency AS frequency +FROM + payments +WHERE + id = ? + AND deleted_at IS NULL + "#; + + let res = sqlx::query_as::<_, payment::Form>(query) + .bind(id) + .fetch_one(pool) + .await; + + match res { + Ok(p) => Some(p), + Err(err) => { + error!("Error looking for payment {}: {:?}", id, err); + None + } + } +} + +pub async fn create(pool: &SqlitePool, p: &payment::Create) -> Option { + let res = sqlx::query( + r#" +INSERT INTO + payments(name, cost, user_id, category_id, date, frequency, created_at) +VALUES + (?, ?, ?, ?, ?, ?, datetime()) + "#, + ) + .bind(p.name.clone()) + .bind(p.cost) + .bind(p.user_id) + .bind(p.category_id) + .bind(p.date) + .bind(p.frequency) + .execute(pool) + .await; + + match res { + Ok(x) => Some(x.last_insert_rowid()), + Err(err) => { + error!("Error creating payment: {:?}", err); + None + } + } +} + +pub async fn update(pool: &SqlitePool, id: i64, p: &payment::Update) -> bool { + let res = sqlx::query( + r#" +UPDATE + payments +SET + name = ?, + cost = ?, + user_id = ?, + category_id = ?, + date = ?, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(p.name.clone()) + .bind(p.cost) + .bind(p.user_id) + .bind(p.category_id) + .bind(p.date) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating payment {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + payments +SET + deleted_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error deleting payment {}: {:?}", id, err); + false + } + } +} + +pub async fn search_category( + pool: &SqlitePool, + payment_name: String, +) -> Option { + let query = format!( + r#" +SELECT + category_id +FROM + payments +WHERE + deleted_at IS NULL + AND {} LIKE ? +ORDER BY + updated_at, created_at + "#, + utils::format_key_for_search("name") + ); + + let res = sqlx::query(&query) + .bind(text::format_search(&format!("%{}%", payment_name))) + .map(|row: SqliteRow| row.get("category_id")) + .fetch_one(pool) + .await; + + match res { + Ok(category) => Some(category), + Err(Error::RowNotFound) => None, + Err(err) => { + error!( + "Error looking for the category of {}: {:?}", + payment_name, err + ); + None + } + } +} + +pub async fn is_category_used(pool: &SqlitePool, category_id: i64) -> bool { + let query = r#" +SELECT + 1 +FROM + payments +WHERE + category_id = ? + AND deleted_at IS NULL +LIMIT + 1 + "#; + + let res = sqlx::query(&query).bind(category_id).fetch_one(pool).await; + + match res { + Ok(_) => true, + Err(Error::RowNotFound) => false, + Err(err) => { + error!( + "Error looking if category {} is used: {:?}", + category_id, err + ); + false + } + } +} + +pub async fn repartition(pool: &SqlitePool) -> HashMap { + let query = r#" +SELECT + users.id AS user_id, + COALESCE(payments.sum, 0) AS sum +FROM + users +LEFT OUTER JOIN ( + SELECT + user_id, + SUM(cost) AS sum + FROM + payments + WHERE + deleted_at IS NULL + AND frequency = 'Punctual' + GROUP BY + user_id +) payments +ON + users.id = payments.user_id"#; + + let res = sqlx::query(&query) + .map(|row: SqliteRow| (row.get("user_id"), row.get("sum"))) + .fetch_all(pool) + .await; + + match res { + Ok(costs) => HashMap::from_iter(costs), + Err(err) => { + error!("Error getting payments repartition: {:?}", err); + HashMap::new() + } + } +} + +pub async fn create_monthly_payments(pool: &SqlitePool) -> () { + let query = r#" +INSERT INTO + payments(name, cost, user_id, category_id, date, frequency, created_at) +SELECT + name, + cost, + user_id, + category_id, + date() AS date, + 'Punctual' AS frequency, + datetime() AS created_at +FROM + payments +WHERE + frequency = 'Monthly' + AND deleted_at IS NULL + "#; + + let res = sqlx::query(query).execute(pool).await; + + match res { + Ok(_) => (), + Err(err) => { + error!("Error creating monthly payments: {:?}", err); + () + } + } +} + +pub async fn last_week(pool: &SqlitePool) -> Vec { + let query = r#" +SELECT + strftime('%d/%m/%Y', payments.date) AS date, + (payments.name || ' (' || users.name || ')') AS name, + payments.cost AS amount, + (CASE + WHEN + payments.deleted_at IS NOT NULL + THEN + 'Deleted' + WHEN + payments.updated_at IS NOT NULL + AND payments.created_at < date('now', 'weekday 0', '-13 days') + THEN + 'Updated' + ELSE + 'Created' + END) AS action +FROM + payments +INNER JOIN + users +ON + payments.user_id = users.id +WHERE + payments.frequency = 'Punctual' + AND ( + ( + payments.created_at >= date('now', 'weekday 0', '-13 days') + AND payments.created_at < date('now', 'weekday 0', '-6 days') + ) OR ( + payments.updated_at >= date('now', 'weekday 0', '-13 days') + AND payments.updated_at < date('now', 'weekday 0', '-6 days') + ) OR ( + payments.deleted_at >= date('now', 'weekday 0', '-13 days') + AND payments.deleted_at < date('now', 'weekday 0', '-6 days') + ) + ) +ORDER BY + payments.date + "#; + + let res = sqlx::query_as::<_, Report>(query).fetch_all(pool).await; + + match res { + Ok(payments) => payments, + Err(err) => { + error!("Error listing payments for report: {:?}", err); + vec![] + } + } +} diff --git a/src/db/users.rs b/src/db/users.rs new file mode 100644 index 0000000..82326a9 --- /dev/null +++ b/src/db/users.rs @@ -0,0 +1,144 @@ +use sqlx::error::Error; +use sqlx::sqlite::{SqlitePool, SqliteRow}; +use sqlx_core::row::Row; + +use crate::model::user::User; + +pub async fn list(pool: &SqlitePool) -> Vec { + let res = sqlx::query_as::<_, User>( + r#" +SELECT + id, + name, + email +FROM + users +ORDER BY + name + "#, + ) + .fetch_all(pool) + .await; + + match res { + Ok(users) => users, + Err(err) => { + error!("Error listing users: {:?}", err); + vec![] + } + } +} + +pub async fn set_login_token( + pool: &SqlitePool, + email: String, + login_token: String, +) -> bool { + let res = sqlx::query( + r#" +UPDATE + users +SET + login_token = ?, + updated_at = datetime() +WHERE + email = ? + "#, + ) + .bind(login_token) + .bind(email) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error updating login token: {:?}", err); + false + } + } +} + +pub async fn remove_login_token(pool: &SqlitePool, id: i64) -> bool { + let res = sqlx::query( + r#" +UPDATE + users +SET + login_token = NULL, + updated_at = datetime() +WHERE + id = ? + "#, + ) + .bind(id) + .execute(pool) + .await; + + match res { + Ok(_) => true, + Err(err) => { + error!("Error removing login token: {:?}", err); + false + } + } +} + +pub async fn get_by_login_token( + pool: &SqlitePool, + login_token: String, +) -> Option { + let res = sqlx::query_as::<_, User>( + r#" +SELECT + id, + name, + email +FROM + users +WHERE + login_token = ? + "#, + ) + .bind(login_token) + .fetch_one(pool) + .await; + + match res { + Ok(user) => Some(user), + Err(Error::RowNotFound) => None, + Err(err) => { + error!("Error getting user from login token: {:?}", err); + None + } + } +} + +pub async fn get_password_hash( + pool: &SqlitePool, + email: String, +) -> Option { + let res = sqlx::query( + r#" +SELECT + password +FROM + users +WHERE + email = ? + "#, + ) + .bind(email) + .map(|row: SqliteRow| row.get("password")) + .fetch_one(pool) + .await; + + match res { + Ok(hash) => Some(hash), + Err(Error::RowNotFound) => None, + Err(err) => { + error!("Error getting password hash: {:?}", err); + None + } + } +} diff --git a/src/db/utils.rs b/src/db/utils.rs new file mode 100644 index 0000000..621a69c --- /dev/null +++ b/src/db/utils.rs @@ -0,0 +1,3 @@ +pub fn format_key_for_search(value: &str) -> String { + format!("replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower({}), 'à', 'a'), 'â', 'a'), 'ç', 'c'), 'è', 'e'), 'é', 'e'), 'ê', 'e'), 'ë', 'e'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ù', 'u'), 'û', 'u'), 'ü', 'u')", value) +} diff --git a/src/jobs/jobs.rs b/src/jobs/jobs.rs new file mode 100644 index 0000000..3e54624 --- /dev/null +++ b/src/jobs/jobs.rs @@ -0,0 +1,28 @@ +use sqlx::sqlite::SqlitePool; +use tera::Tera; +use tokio::time::{delay_for, Duration}; + +use crate::db; +use crate::jobs::weekly_report; +use crate::model::config::Config; +use crate::model::job::Job; + +pub async fn start(config: Config, pool: SqlitePool, templates: Tera) -> () { + loop { + if db::jobs::should_run(&pool, Job::WeeklyReport).await { + info!("Starting weekly report job"); + if weekly_report::send(&config, &pool, &templates).await { + db::jobs::actualize_last_execution(&pool, Job::WeeklyReport) + .await; + } + } + if db::jobs::should_run(&pool, Job::MonthlyPayment).await { + info!("Starting monthly payment job"); + db::payments::create_monthly_payments(&pool).await; + db::jobs::actualize_last_execution(&pool, Job::MonthlyPayment) + .await; + } + // Sleeping 8 hours + delay_for(Duration::from_secs(8 * 60 * 60)).await; + } +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs new file mode 100644 index 0000000..be2ddac --- /dev/null +++ b/src/jobs/mod.rs @@ -0,0 +1,2 @@ +pub mod jobs; +mod weekly_report; diff --git a/src/jobs/weekly_report.rs b/src/jobs/weekly_report.rs new file mode 100644 index 0000000..819d30b --- /dev/null +++ b/src/jobs/weekly_report.rs @@ -0,0 +1,55 @@ +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use tera::{Context, Tera}; + +use crate::db; +use crate::mail; +use crate::model::config::Config; +use crate::payer; + +pub async fn send( + config: &Config, + pool: &SqlitePool, + templates: &Tera, +) -> bool { + match get_weekly_report(pool, templates).await { + Ok(report) => { + let users = db::users::list(pool).await; + mail::send( + config, + users.into_iter().map(|u| (u.email, u.name)).collect(), + "Budget — rapport hebdomadaire".to_string(), + report, + ) + } + Err(err) => { + error!("Error preparing weekly report from template: {:?}", err); + false + } + } +} + +async fn get_weekly_report( + pool: &SqlitePool, + templates: &Tera, +) -> Result { + let users = db::users::list(pool).await; + let incomes_from = db::incomes::defined_for_all(pool).await; + let user_incomes = match incomes_from { + Some(from) => db::incomes::cumulative(pool, from).await, + None => HashMap::new(), + }; + let user_payments = db::payments::repartition(pool).await; + let exceeding_payers = + payer::exceeding(&users, &user_incomes, &user_payments); + + let last_week_payments = db::payments::last_week(pool).await; + let last_week_incomes = db::incomes::last_week(pool).await; + + let mut context = Context::new(); + context.insert("exceeding_payers", &exceeding_payers); + context.insert("payments", &last_week_payments); + context.insert("incomes", &last_week_incomes); + + templates.render("report/report.j2", &context) +} diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..d86cff3 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,59 @@ +use lettre::sendmail::SendmailTransport; +use lettre::{SendableEmail, Transport}; +use lettre_email::Email; + +use crate::model::config::Config; + +static FROM: &str = "contact@guyonvarch.me"; + +pub fn send( + config: &Config, + to: Vec<(String, String)>, + subject: String, + message: String, +) -> bool { + match prepare_email(to.clone(), subject.clone(), message.clone()) { + Ok(email) => { + if config.mock_mails { + let formatted_to = to + .into_iter() + .map(|t| t.0) + .collect::>() + .join(", "); + info!( + "MOCK MAIL\nto: {}\nsubject: {}\n\n{}", + formatted_to, subject, message + ); + true + } else { + let mut sender = + SendmailTransport::new_with_command(&config.sendmail_path); + match sender.send(email) { + Ok(_) => true, + Err(err) => { + error!("Error sending email: {:?}", err); + false + } + } + } + } + Err(err) => { + error!("Error preparing email: {:?}", err); + false + } + } +} + +fn prepare_email( + to: Vec<(String, String)>, + subject: String, + message: String, +) -> Result { + let mut email = Email::builder().from(FROM).subject(subject).text(message); + + for (address, name) in to.iter() { + email = email.to((address, name)); + } + + email.build().map(|e| e.into()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..502f608 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,88 @@ +use hyper::service::{make_service_fn, service_fn}; +use hyper::Server; +use sqlx::sqlite::SqlitePool; +use std::convert::Infallible; +use std::net::SocketAddr; +use structopt::StructOpt; + +#[macro_use] +extern crate log; + +mod assets; +mod controller; +mod db; +mod jobs; +mod mail; +mod model; +mod payer; +mod queries; +mod routes; +mod templates; +mod utils; +mod validation; + +use model::config::Config; + +#[derive(StructOpt)] +#[structopt()] +struct Opt { + #[structopt(short, long, default_value = "127.0.0.1:3000")] + address: SocketAddr, + + #[structopt(short, long, default_value = "config.json")] + config: String, + + #[structopt(short, long, default_value = "database.db")] + database: String, +} + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("warn"), + ) + .init(); + + let opt = Opt::from_args(); + + let config_str = std::fs::read_to_string(&opt.config) + .expect(&format!("Missing {}", opt.config)); + let config: Config = serde_json::from_str(&config_str).unwrap(); + + let pool = SqlitePool::connect(&format!("sqlite:{}", opt.database)) + .await + .unwrap(); + + let assets = assets::get(); + + let templates = templates::get(); + + tokio::spawn(jobs::jobs::start( + config.clone(), + pool.clone(), + templates.clone(), + )); + + let make_svc = make_service_fn(|_conn| { + let config = config.clone(); + let pool = pool.clone(); + let assets = assets.clone(); + let templates = templates.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |req| { + routes::routes( + config.clone(), + pool.clone(), + assets.clone(), + templates.clone(), + req, + ) + })) + } + }); + + info!("Starting server at {}", opt.address); + if let Err(e) = Server::bind(&opt.address).serve(make_svc).await { + error!("server error: {}", e); + } +} diff --git a/src/model/action.rs b/src/model/action.rs new file mode 100644 index 0000000..a77543a --- /dev/null +++ b/src/model/action.rs @@ -0,0 +1 @@ +use serde::Serialize; diff --git a/src/model/category.rs b/src/model/category.rs new file mode 100644 index 0000000..de08dea --- /dev/null +++ b/src/model/category.rs @@ -0,0 +1,20 @@ +use serde::Serialize; + +#[derive(sqlx::FromRow, Serialize)] +pub struct Category { + pub id: i64, + pub name: String, + pub color: String, +} + +#[derive(Debug)] +pub struct Create { + pub name: String, + pub color: String, +} + +#[derive(Debug)] +pub struct Update { + pub name: String, + pub color: String, +} diff --git a/src/model/config.rs b/src/model/config.rs new file mode 100644 index 0000000..8d304e5 --- /dev/null +++ b/src/model/config.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub struct Config { + pub secure_cookies: bool, + pub mock_mails: bool, + pub sendmail_path: String, +} diff --git a/src/model/frequency.rs b/src/model/frequency.rs new file mode 100644 index 0000000..bb83e27 --- /dev/null +++ b/src/model/frequency.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt, str}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, sqlx::Type)] +pub enum Frequency { + Punctual, + Monthly, +} + +impl fmt::Display for Frequency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Frequency::Punctual => write!(f, "Punctual"), + Frequency::Monthly => write!(f, "Monthly"), + } + } +} + +pub struct ParseFrequencyError; + +impl str::FromStr for Frequency { + type Err = ParseFrequencyError; + + fn from_str(s: &str) -> Result { + match s { + "Punctual" => Ok(Frequency::Punctual), + "Monthly" => Ok(Frequency::Monthly), + _ => Err(ParseFrequencyError {}), + } + } +} diff --git a/src/model/income.rs b/src/model/income.rs new file mode 100644 index 0000000..7bc888f --- /dev/null +++ b/src/model/income.rs @@ -0,0 +1,40 @@ +use serde::Serialize; + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Stat { + pub date: String, + pub amount: i64, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Table { + pub id: i64, + pub date: String, + pub user: String, + pub amount: i64, +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct Form { + pub id: i64, + pub amount: i64, + pub user_id: i64, + pub month: i64, + pub year: i64, +} + +#[derive(Debug)] +pub struct Create { + pub user_id: i64, + pub amount: i64, + pub month: u32, + pub year: i32, +} + +#[derive(Debug)] +pub struct Update { + pub user_id: i64, + pub amount: i64, + pub month: u32, + pub year: i32, +} diff --git a/src/model/job.rs b/src/model/job.rs new file mode 100644 index 0000000..74151ae --- /dev/null +++ b/src/model/job.rs @@ -0,0 +1,5 @@ +#[derive(Debug, sqlx::Type)] +pub enum Job { + MonthlyPayment, + WeeklyReport, +} diff --git a/src/model/login.rs b/src/model/login.rs new file mode 100644 index 0000000..c7a10ba --- /dev/null +++ b/src/model/login.rs @@ -0,0 +1,5 @@ +#[derive(Debug)] +pub struct Login { + pub email: String, + pub password: String, +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..fb07721 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,9 @@ +pub mod category; +pub mod config; +pub mod frequency; +pub mod income; +pub mod job; +pub mod login; +pub mod payment; +pub mod report; +pub mod user; diff --git a/src/model/payment.rs b/src/model/payment.rs new file mode 100644 index 0000000..5ce6bb9 --- /dev/null +++ b/src/model/payment.rs @@ -0,0 +1,53 @@ +use chrono::NaiveDate; +use serde::Serialize; + +use crate::model::frequency::Frequency; + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Table { + pub id: i64, + pub name: String, + pub cost: i64, + pub user: String, + pub category_name: String, + pub category_color: String, + pub date: String, + pub frequency: Frequency, +} + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Form { + pub id: i64, + pub name: String, + pub cost: i64, + pub user_id: i64, + pub category_id: i64, + pub date: String, + pub frequency: Frequency, +} + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Stat { + pub start_date: String, + pub cost: i64, + pub category_id: i64, +} + +#[derive(Debug)] +pub struct Create { + pub name: String, + pub cost: i64, + pub user_id: i64, + pub category_id: i64, + pub date: NaiveDate, + pub frequency: Frequency, +} + +#[derive(Debug)] +pub struct Update { + pub name: String, + pub cost: i64, + pub user_id: i64, + pub category_id: i64, + pub date: NaiveDate, +} diff --git a/src/model/report.rs b/src/model/report.rs new file mode 100644 index 0000000..4858402 --- /dev/null +++ b/src/model/report.rs @@ -0,0 +1,16 @@ +use serde::Serialize; + +#[derive(Debug, sqlx::FromRow, Serialize)] +pub struct Report { + pub date: String, + pub name: String, + pub amount: i64, + pub action: Action, +} + +#[derive(Debug, PartialEq, Serialize, sqlx::Type)] +pub enum Action { + Created, + Updated, + Deleted, +} diff --git a/src/model/user.rs b/src/model/user.rs new file mode 100644 index 0000000..e8a61bf --- /dev/null +++ b/src/model/user.rs @@ -0,0 +1,8 @@ +use serde::Serialize; + +#[derive(Debug, sqlx::FromRow, Clone, Serialize)] +pub struct User { + pub id: i64, + pub name: String, + pub email: String, +} diff --git a/src/payer.rs b/src/payer.rs new file mode 100644 index 0000000..48cee52 --- /dev/null +++ b/src/payer.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use crate::model::user::User; + +pub fn exceeding( + users: &Vec, + user_incomes: &HashMap, + user_payments: &HashMap, +) -> Vec<(String, i64)> { + let ratios = users.into_iter().map(|u| { + let income = *user_incomes.get(&u.id).unwrap_or(&0); + if income == 0 { + (u.name.clone(), 0, 0.0) + } else { + let payments = *user_payments.get(&u.id).unwrap_or(&0); + let ratio = payments as f64 / income as f64; + (u.name.clone(), income, ratio) + } + }); + let min_ratio = ratios + .clone() + .map(|r| r.2) + .min_by(|r1, r2| { + ((r1 * 100_000_000.0).round() as i64) + .cmp(&((r2 * 100_000_000.0).round() as i64)) + }) + .unwrap_or(0.0); + ratios + .filter_map(|r| { + let exceeding = ((r.2 - min_ratio) * r.1 as f64).round() as i64; + if exceeding == 0 { + None + } else { + Some((r.0, exceeding)) + } + }) + .collect() +} diff --git a/src/queries.rs b/src/queries.rs new file mode 100644 index 0000000..a7ba28c --- /dev/null +++ b/src/queries.rs @@ -0,0 +1,62 @@ +use crate::model::frequency::Frequency; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone)] +pub struct Payments { + pub page: Option, + pub search: Option, + pub frequency: Option, + pub highlight: Option, +} + +pub fn payments_url(q: Payments) -> String { + let mut params = Vec::new(); + + match q.page { + None | Some(1) => (), + Some(p) => params.push(format!("page={}", p)), + }; + + match q.search { + Some(s) => { + if !s.is_empty() { + params.push(format!("search={}", s)); + } + } + _ => (), + }; + + match q.frequency { + Some(Frequency::Monthly) => { + params.push("frequency=Monthly".to_string()) + } + _ => (), + }; + + match q.highlight { + Some(id) => params.push(format!("highlight={}", id)), + _ => (), + }; + + if params.is_empty() { + "".to_string() + } else { + format!("?{}", params.join("&")) + } +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Incomes { + pub page: Option, + pub highlight: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Categories { + pub highlight: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct PaymentCategory { + pub payment_name: String, +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..3d76ab1 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,219 @@ +use hyper::{Body, Method, Request, Response}; +use serde::Deserialize; +use serde_urlencoded; +use sqlx::sqlite::SqlitePool; +use std::collections::HashMap; +use std::convert::Infallible; +use tera::Tera; +use url::form_urlencoded; + +use crate::controller; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::model::config::Config; +use crate::model::user::User; + +pub async fn routes( + config: Config, + pool: SqlitePool, + assets: HashMap, + templates: Tera, + request: Request, +) -> Result, Infallible> { + let method = request.method(); + let uri = request.uri(); + let path = &uri.path().split('/').collect::>()[1..]; + + let response = match (method, path) { + (&Method::GET, ["login"]) => { + controller::login::page(&assets, &templates, None).await + } + (&Method::POST, ["login"]) => { + controller::login::login( + config, + &assets, + &templates, + body_form(request).await, + pool, + ) + .await + } + (&Method::GET, ["assets", _, file]) => { + controller::utils::file(&format!("assets/{}", file)).await + } + _ => match connected_user(&pool, &request).await { + Some(user) => { + let wallet = Wallet { + pool, + assets, + templates, + user, + }; + authenticated_routes(config, wallet, request).await + } + None => controller::utils::redirect("/login"), + }, + }; + + Ok(response) +} + +async fn connected_user( + pool: &SqlitePool, + request: &Request, +) -> Option { + let cookie = request.headers().get("COOKIE")?.to_str().ok()?; + let mut xs = cookie.split('='); + xs.next(); + let login_token = xs.next()?; + db::users::get_by_login_token(&pool, login_token.to_string()).await +} + +async fn authenticated_routes( + config: Config, + wallet: Wallet, + request: Request, +) -> Response { + let method = request.method(); + let uri = request.uri(); + let path = &uri.path().split('/').collect::>()[1..]; + let query = uri.query(); + + match (method, path) { + (&Method::GET, [""]) => { + controller::payments::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["payment"]) => { + controller::payments::create_form(&wallet, parse_query(query)).await + } + (&Method::POST, ["payment", "create"]) => { + controller::payments::create( + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::GET, ["payment", "category"]) => { + controller::payments::search_category(&wallet, parse_query(query)) + .await + } + (&Method::GET, ["payment", id]) => { + controller::payments::update_form( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::POST, ["payment", id, "update"]) => { + controller::payments::update( + parse_id(id), + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::POST, ["payment", id, "delete"]) => { + controller::payments::delete( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::GET, ["incomes"]) => { + controller::incomes::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["income"]) => { + controller::incomes::create_form(&wallet, parse_query(query)).await + } + (&Method::POST, ["income", "create"]) => { + controller::incomes::create( + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::GET, ["income", id]) => { + controller::incomes::update_form( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::POST, ["income", id, "update"]) => { + controller::incomes::update( + parse_id(id), + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::POST, ["income", id, "delete"]) => { + controller::incomes::delete( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::GET, ["categories"]) => { + controller::categories::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["category"]) => { + controller::categories::create_form(&wallet).await + } + (&Method::POST, ["category", "create"]) => { + controller::categories::create(&wallet, body_form(request).await) + .await + } + (&Method::GET, ["category", id]) => { + controller::categories::update_form(parse_id(id), &wallet).await + } + (&Method::POST, ["category", id, "update"]) => { + controller::categories::update( + parse_id(id), + &wallet, + body_form(request).await, + ) + .await + } + (&Method::POST, ["category", id, "delete"]) => { + controller::categories::delete(parse_id(id), &wallet).await + } + (&Method::GET, ["balance"]) => controller::balance::get(&wallet).await, + (&Method::GET, ["statistics"]) => { + controller::statistics::get(&wallet).await + } + (&Method::POST, ["logout"]) => { + controller::login::logout(config, &wallet).await + } + _ => controller::error::error( + &wallet, + "Page introuvable", + "La page que recherchez n’existe pas.", + ), + } +} + +fn parse_query<'a, T: Deserialize<'a>>(query: Option<&'a str>) -> T { + serde_urlencoded::from_str(query.unwrap_or("")).unwrap() +} + +async fn body_form(request: Request) -> HashMap { + match hyper::body::to_bytes(request).await { + Ok(bytes) => form_urlencoded::parse(bytes.as_ref()) + .into_owned() + .collect::>(), + Err(_) => HashMap::new(), + } +} + +fn parse_id(str: &str) -> i64 { + str.parse::().unwrap() +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..7e3753a --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,97 @@ +use serde::Serialize; +use serde_json::json; +use serde_json::value::Value; +use std::collections::HashMap; +use tera::Tera; +use tera::{Error, Result}; + +use crate::queries; + +#[derive(Debug, Serialize)] +pub enum Header { + Payments, + Categories, + Incomes, + Balance, + Statistics, +} + +pub fn get() -> Tera { + let mut tera = match Tera::new("templates/**/*") { + Ok(t) => t, + Err(e) => { + error!("Parsing error(s): {}", e); + ::std::process::exit(1); + } + }; + tera.register_function("payments_params", payments_params); + tera.register_filter("numeric", numeric); + tera.register_filter("euros", euros); + tera +} + +fn payments_params(args: &HashMap) -> Result { + let q = json!({ + "page": args.get("page"), + "search": args.get("search"), + "frequency": args.get("frequency"), + "highlight": args.get("highlight"), + }); + + match serde_json::from_value(q) { + Ok(q) => Ok(json!(queries::payments_url(q))), + Err(msg) => Err(Error::msg(msg)), + } +} + +fn euros(value: &Value, _: &HashMap) -> Result { + match value { + Value::Number(n) => { + if let Some(n) = n.as_i64() { + let str = rgrouped(n.abs().to_string(), 3).join(" "); + let sign = if n < 0 { "-" } else { "" }; + Ok(json!(format!("{}{} €", sign, str))) + } else if let Some(n) = n.as_f64() { + Ok(json!(format!("{} €", n.to_string()))) + } else { + Err(Error::msg("Error parsing number")) + } + } + _ => Err(Error::msg(format!("{:?} should be a number", value))), + } +} + +fn numeric(value: &Value, _: &HashMap) -> Result { + match value { + Value::Number(n) => { + if let Some(n) = n.as_i64() { + let str = rgrouped(n.abs().to_string(), 3).join(" "); + let sign = if n < 0 { "-" } else { "" }; + Ok(json!(format!("{}{}", sign, str))) + } else if let Some(n) = n.as_f64() { + Ok(json!(format!("{}", n.to_string()))) + } else { + Err(Error::msg("Error parsing number")) + } + } + _ => Err(Error::msg(format!("{:?} should be a number", value))), + } +} + +fn rgrouped(str: String, n: usize) -> Vec { + let mut str = str.clone(); + let mut l = str.len(); + let mut res = vec![]; + while l > n { + let str2 = str.clone(); + let (start, end) = str2.split_at(l - n); + l -= n; + str = start.to_string(); + res.push(end.to_string()); + } + if !str.is_empty() { + res.push(str); + } + res.reverse(); + res +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..481c63a --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod text; diff --git a/src/utils/text.rs b/src/utils/text.rs new file mode 100644 index 0000000..c07ccee --- /dev/null +++ b/src/utils/text.rs @@ -0,0 +1,19 @@ +pub fn format_search(str: &String) -> String { + unaccent(&str.to_lowercase()) +} + +pub fn unaccent(str: &String) -> String { + str.chars().map(unaccent_char).collect() +} + +pub fn unaccent_char(c: char) -> char { + match c { + 'à' | 'â' => 'a', + 'ç' => 'c', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'î' | 'ï' => 'i', + 'ô' => 'o', + 'ù' | 'û' | 'ü' => 'u', + _ => c, + } +} diff --git a/src/validation/category.rs b/src/validation/category.rs new file mode 100644 index 0000000..7b1b5d5 --- /dev/null +++ b/src/validation/category.rs @@ -0,0 +1,18 @@ +use std::collections::HashMap; + +use crate::model::category::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + Some(Create { + name: non_empty(form, "name")?, + color: color(form, "color")?, + }) +} + +pub fn update(form: &HashMap) -> Option { + Some(Update { + name: non_empty(form, "name")?, + color: color(form, "color")?, + }) +} diff --git a/src/validation/income.rs b/src/validation/income.rs new file mode 100644 index 0000000..972e42a --- /dev/null +++ b/src/validation/income.rs @@ -0,0 +1,22 @@ +use std::collections::HashMap; + +use crate::model::income::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + Some(Create { + user_id: parse::(form, "user_id")?, + amount: parse::(form, "amount")?, + month: parse::(form, "month")?, + year: parse::(form, "year")?, + }) +} + +pub fn update(form: &HashMap) -> Option { + Some(Update { + user_id: parse::(form, "user_id")?, + amount: parse::(form, "amount")?, + month: parse::(form, "month")?, + year: parse::(form, "year")?, + }) +} diff --git a/src/validation/login.rs b/src/validation/login.rs new file mode 100644 index 0000000..e40bb23 --- /dev/null +++ b/src/validation/login.rs @@ -0,0 +1,11 @@ +use std::collections::HashMap; + +use crate::model::login::Login; +use crate::validation::utils::*; + +pub fn login(form: &HashMap) -> Option { + Some(Login { + email: non_empty(form, "email")?, + password: non_empty(form, "password")?, + }) +} diff --git a/src/validation/mod.rs b/src/validation/mod.rs new file mode 100644 index 0000000..181abc7 --- /dev/null +++ b/src/validation/mod.rs @@ -0,0 +1,5 @@ +pub mod category; +pub mod income; +pub mod login; +pub mod payment; +pub mod utils; diff --git a/src/validation/payment.rs b/src/validation/payment.rs new file mode 100644 index 0000000..36aa852 --- /dev/null +++ b/src/validation/payment.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; + +use crate::model::payment::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + Some(Create { + name: non_empty(form, "name")?, + cost: parse::(form, "cost")?, + user_id: parse::(form, "user_id")?, + category_id: parse::(form, "category_id")?, + date: date(form, "date")?, + frequency: frequency(form, "frequency")?, + }) +} + +pub fn update(form: &HashMap) -> Option { + Some(Update { + name: non_empty(form, "name")?, + cost: parse::(form, "cost")?, + user_id: parse::(form, "user_id")?, + category_id: parse::(form, "category_id")?, + date: date(form, "date")?, + }) +} diff --git a/src/validation/utils.rs b/src/validation/utils.rs new file mode 100644 index 0000000..4bff40a --- /dev/null +++ b/src/validation/utils.rs @@ -0,0 +1,54 @@ +use chrono::NaiveDate; +use std::collections::HashMap; +use std::str::FromStr; + +use crate::model::frequency::Frequency; + +pub fn non_empty( + form: &HashMap, + field: &str, +) -> Option { + let s = form.get(field)?.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + +pub fn parse( + form: &HashMap, + field: &str, +) -> Option { + let s = form.get(field)?; + s.parse::().ok() +} + +pub fn date(form: &HashMap, field: &str) -> Option { + let s = form.get(field)?; + NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() +} + +pub fn frequency( + form: &HashMap, + field: &str, +) -> Option { + let s = form.get(field)?; + Frequency::from_str(s).ok() +} + +pub fn color(form: &HashMap, field: &str) -> Option { + let s = form.get(field)?; + if s.len() == 7 + && &s[0..1] == "#" + && s[1..] + .to_string() + .into_bytes() + .into_iter() + .all(|c| c.is_ascii_hexdigit()) + { + Some(s.to_string()) + } else { + None + } +} diff --git a/templates/balance.html b/templates/balance.html new file mode 100644 index 0000000..15da854 --- /dev/null +++ b/templates/balance.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} + +{% block title %} + Équilibre +{% endblock title %} + +{% block main %} + +
+ {% if exceeding_payers %} +
    + {% for exceeding_payer in exceeding_payers %} +
  • + {{ exceeding_payer.0 }} : +{{ exceeding_payer.1 | euros() }} +
  • + {% endfor %} +
+ {% else %} +

+ Les paiements sont équilibrés. +

+ {% endif %} + + {% if incomes_from %} +

+ Revenus +

+ +
+
+ + Montant + Part +
+ {% for user_income in user_incomes %} +
+ + {{ user_income.0 }} + + + {{ user_income.1 | euros() }} + + + {% if total_income > 0 %} + {{ user_income.1 / total_income * 100 | round() }} % + {% else %} + – + {% endif %} + +
+ {% endfor %} +
+ + Total + + + {{ total_income | euros() }} + + + 100 % + +
+
+ {% endif %} + +

+ Paiements +

+ +
+
+ + Montant + Part +
+ {% for user_payment in user_payments %} +
+ + {{ user_payment.0 }} + + + {{ user_payment.1 | euros() }} + + + {% if total_payments > 0 %} + {{ user_payment.1 / total_payments * 100 | round() }} % + {% else %} + – + {% endif %} + +
+ {% endfor %} +
+ + Total + + + {{ total_payments | euros() }} + + + 100 % + +
+
+
+ +{% endblock main %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f403d41 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,91 @@ + + + + + + Budget — {% block title %}{% endblock title %} + + + + + + +
+ +
+
Budget
+ {% if connected_user %} + + {{ connected_user.name }} + + + {% endif %} +
+ + {% if connected_user %} + + {% endif %} + +
+ +
+ {% block main %}{% endblock main %} +
+ + + + + diff --git a/templates/category/create.html b/templates/category/create.html new file mode 100644 index 0000000..e206898 --- /dev/null +++ b/templates/category/create.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %} + Nouvelle catégorie +{% endblock title %} + +{% block main %} + +
+

+ + Retour aux categories + +

+ +
+

+ Nouvelle catégorie +

+ + {% if error %} +
{{ error }}
+ {% endif %} + + + + + + + +
+ +
+ +
+ +{% endblock main %} diff --git a/templates/category/table.html b/templates/category/table.html new file mode 100644 index 0000000..896304a --- /dev/null +++ b/templates/category/table.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %} + Catégories +{% endblock title %} + +{% block main %} + +
+ Nouveau +
+ + {% if not categories %} + +
+ Il n’y a aucune catégorie. +
+ + {% else %} + +
+ {% for category in categories %} + + + {{ category.name }} + + + {% endfor %} +
+ + {% endif %} +{% endblock main %} diff --git a/templates/category/update.html b/templates/category/update.html new file mode 100644 index 0000000..a4c1481 --- /dev/null +++ b/templates/category/update.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} + +{% block title %} + Catégorie {{ id }} +{% endblock title %} + +{% block main %} + +
+

+ + Retour aux catégories + +

+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if not category %} + + La catégorie n’a pas été trouvée. + + {% else %} + +
+

Modification

+ + + + + + + +
+ +
+ + +
+

Suppression

+ + {% if is_category_used %} +

+ La catégorie ne peut pas être supprimée car elle est actuellement + utilisée. +

+ {% else %} + + + + +
+ +
+ {% endif %} + + + {% endif %} +
+ +{% endblock main %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..b3049a9 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %} + {{ title }} +{% endblock title %} + +{% block main %} +

+ {{ message }} +

+ +

+ + Retour à l’accueil + +

+{% endblock main %} diff --git a/templates/income/create.html b/templates/income/create.html new file mode 100644 index 0000000..b74dddd --- /dev/null +++ b/templates/income/create.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block title %} + Nouveau revenu +{% endblock title %} + +{% block main %} + +
+

+ + Retour aux revenus + +

+ +
+

+ Nouveau revenu +

+ + {% if error %} +
{{ error }}
+ {% endif %} + + + + + {% set user_id = form.user_id | default(value="" ~ connected_user.id) %} + + + + + {% set month_index = form.month | default(value="" ~ current_month) %} + + + + + + + +
+ +
+ +
+ +{% endblock main %} diff --git a/templates/income/table.html b/templates/income/table.html new file mode 100644 index 0000000..efd82a7 --- /dev/null +++ b/templates/income/table.html @@ -0,0 +1,55 @@ +{% import "macros/paging.html" as paging %} + +{% extends "base.html" %} + +{% block title %} + Revenus +{% endblock title %} + +{% block main %} + + + + {% if not incomes %} + +
+ Il n’y a aucun revenu. +
+ + {% else %} + +
+
+ Montant + Personne + Mois +
+ {% for income in incomes %} + + + {{ income.amount | euros() }} + + {{ income.user }} + {{ income.date }} + + {% endfor %} +
+ + {{ paging::paging( + url="/incomes", + page=page, + max_page=max_page + ) }} + + {% endif %} +{% endblock main %} diff --git a/templates/income/update.html b/templates/income/update.html new file mode 100644 index 0000000..6dd649a --- /dev/null +++ b/templates/income/update.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} + +{% block title %} + Revenu {{ id }} +{% endblock title %} + +{% block main %} + +
+

+ + Retour aux revenus + +

+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if not income %} + + Le revenu n’a pas été trouvé. + + {% else %} + +
+

Modification

+ + + + + {% set user_id = form.user_id | default(value="" ~ income.user_id) %} + + + + + {% set month_index = form.month | default(value="" ~ income.month) %} + + + + + + + +
+ +
+ + +
+

Suppression

+ + {% if is_category_used %} +

+ La catégorie ne peut pas être supprimée car elle est actuellement + utilisée. +

+ {% else %} + + + + +
+ +
+ {% endif %} + + + {% endif %} +
+ +{% endblock main %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..fd4cfe9 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %} + Connexion +{% endblock title %} + +{% block main %} + +
+ + + {% if error %} +
{{ error }}
+ {% endif %} + + + + + + + +
+ +
+ + +
+ +{% endblock main %} diff --git a/templates/macros/paging.html b/templates/macros/paging.html new file mode 100644 index 0000000..59ba617 --- /dev/null +++ b/templates/macros/paging.html @@ -0,0 +1,55 @@ +{% macro paging(url, page, max_page) %} + {% if url is containing("?") %} + {% set sign = "&" %} + {% else %} + {% set sign = "?" %} + {% endif %} + +
+ {% if page > 1 %} + + ❬❬ + + + ❬ + + {% else %} + + ❬❬ + + + ❬ + + {% endif %} + + {{ page }} / {{ max_page }} + + {% if page < max_page %} + + ❭ + + + ❭❭ + + {% else %} + + ❭ + + + ❭❭ + + {% endif %} +
+{% endmacro paging %} diff --git a/templates/payment/create.html b/templates/payment/create.html new file mode 100644 index 0000000..aea6fcd --- /dev/null +++ b/templates/payment/create.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} + +{% block title %} + Nouveau paiement +{% endblock title %} + +{% block main %} + +
+

+ + Retour aux paiements + +

+ +
+

+ Nouveau paiement + {% if query.frequency != "Monthly" %} + ponctuel + {% else %} + mensuel + {% endif %} +

+ + {% if error %} +
{{ error }}
+ {% endif %} + + + + + + + + {% set user_id = form.user_id | default(value="" ~ connected_user.id) %} + + + + + {% set category_id = form.category_id | default(value="") %} + + + + + {% set date = form.date | default(value=now() | date(format="%Y-%m-%d")) %} + + {% if query.frequency != "Monthly" %} + + + {% else %} + + {% endif %} + + + +
+ +
+ +
+ +{% endblock main %} diff --git a/templates/payment/table.html b/templates/payment/table.html new file mode 100644 index 0000000..19b56b4 --- /dev/null +++ b/templates/payment/table.html @@ -0,0 +1,128 @@ +{% import "macros/paging.html" as paging %} + +{% extends "base.html" %} + +{% block title %} + Paiements +{% endblock title %} + +{% block main %} + +
+
+
+ {% if query.frequency == "Monthly" %} + + Ponctuels + + / + + Mensuels + + {% else %} + + Ponctuels + + / + + Mensuels + + {% endif %} +
+ + {% if query.frequency != "Monthly" %} + + + + + {% endif %} +
+ + + Nouveau + +
+ + {% if not payments %} + +
+ Aucun paiement ne correspond à votre recherche. +
+ + {% else %} + +
+ {{ count | numeric }} paiement{{ count | pluralize }} comptabilisant {{ total_cost | euros() }}. +
+ +
+
+ Nom + Coût + Personne + Catégorie + {% if query.frequency != "Monthly" %} + Date + {% endif %} +
+ {% for payment in payments %} + + {{ payment.name }} + + {{ payment.cost | euros() }} + + {{ payment.user }} + + + {{ payment.category_name }} + + + {% if query.frequency != "Monthly" %} + + {{ payment.date }} + + {% endif %} + + {% endfor %} +
+ + {{ paging::paging( + url="/" ~ payments_params( + search=query.search, + frequency=query.frequency + ), + page=page, + max_page=max_page + ) }} + + {% endif %} +{% endblock main %} diff --git a/templates/payment/update.html b/templates/payment/update.html new file mode 100644 index 0000000..25e6915 --- /dev/null +++ b/templates/payment/update.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} + +{% block title %} + Paiement {{ id }} +{% endblock title %} + +{% block main %} + +
+

+ + Retour aux paiements + +

+ + {% if error %} +
{{ error }}
+ {% endif %} + + {% if not payment %} + + Le paiement n’a pas été trouvé. + + {% else %} + +
+

Modification

+ + + + + + + + {% set user_id = form.user_id | default(value="" ~ payment.user_id) %} + + + + + {% set category_id = form.category_id | default(value="" ~ payment.category_id) %} + + + + + {% set date = form.date | default(value=payment.date) %} + + {% if payment.frequency == "Punctual" %} + + + {% else %} + + {% endif %} + +
+ +
+ + +
+

Suppression

+ + + + + +
+ +
+ + + {% endif %} +
+ +{% endblock main %} diff --git a/templates/report/list.j2 b/templates/report/list.j2 new file mode 100644 index 0000000..ef53244 --- /dev/null +++ b/templates/report/list.j2 @@ -0,0 +1,14 @@ +{% macro list(resource, action, xs) -%} + +{% if xs -%} + + {% set s = xs | length | pluralize -%} + + {{ xs | length }} {{ resource }}{{ s }} {{ action }}{{ s }} : + + {% for x in xs -%} + - {{ x.date }} {{ x.name }} {{ x.amount | euros() }} + {% endfor %} +{% endif -%} + +{% endmacro paging %} diff --git a/templates/report/report.j2 b/templates/report/report.j2 new file mode 100644 index 0000000..d36f3ce --- /dev/null +++ b/templates/report/report.j2 @@ -0,0 +1,50 @@ +{% import "report/list.j2" as list %} + +{% if exceeding_payers -%} + + Équilibre : + + {% for exceeding_payer in exceeding_payers -%} + - {{ exceeding_payer.0 }} : +{{ exceeding_payer.1 | euros() }} + {% endfor %} +{% else -%} + + Les paiements sont équilibrés. + +{% endif %}{# + +#}{{ list::list( + resource="paiement", + action="créé", + xs=payments | filter(attribute="action", value="Created") +) }}{# + +#}{{ list::list( + resource="paiement", + action="modifié", + xs=payments | filter(attribute="action", value="Updated") +) }}{# + +#}{{ list::list( + resource="paiement", + action="supprimé", + xs=payments | filter(attribute="action", value="Deleted") +) }}{# + +#}{{ list::list( + resource="revenu", + action="créé", + xs=incomes | filter(attribute="action", value="Created") +) }}{# + +#}{{ list::list( + resource="revenu", + action="modifié", + xs=incomes | filter(attribute="action", value="Updated") +) }}{# + +#}{{ list::list( + resource="revenu", + action="supprimé", + xs=incomes | filter(attribute="action", value="Deleted") +) }} diff --git a/templates/statistics.html b/templates/statistics.html new file mode 100644 index 0000000..badbc6d --- /dev/null +++ b/templates/statistics.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %} + Statistiques +{% endblock title %} + +{% block main %} + +
+ + +
+ + + + + + + + + +{% endblock main %} diff --git a/validation/LICENSE b/validation/LICENSE deleted file mode 100644 index 45644ff..0000000 --- a/validation/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/validation/Setup.hs b/validation/Setup.hs deleted file mode 100644 index 4467109..0000000 --- a/validation/Setup.hs +++ /dev/null @@ -1,2 +0,0 @@ -import Distribution.Simple -main = defaultMain diff --git a/validation/src/Data/Validation.hs b/validation/src/Data/Validation.hs deleted file mode 100644 index e30202f..0000000 --- a/validation/src/Data/Validation.hs +++ /dev/null @@ -1,375 +0,0 @@ -{-# LANGUAGE CPP #-} -{-# LANGUAGE DeriveDataTypeable #-} -{-# LANGUAGE NoImplicitPrelude #-} -{-# LANGUAGE TypeFamilies #-} - -#if __GLASGOW_HASKELL__ >= 702 -{-# LANGUAGE DeriveGeneric #-} -#endif - --- | A data type similar to @Data.Either@ that accumulates failures. -module Data.Validation -( - -- * Data type - Validation(..) - -- * Constructing validations -, validate -, validationNel -, fromEither -, liftError - -- * Functions on validations -, validation -, toEither -, orElse -, valueOr -, ensure -, codiagonal -, validationed -, bindValidation - -- * Prisms - -- | These prisms are useful for writing code which is polymorphic in its - -- choice of Either or Validation. This choice can then be made later by a - -- user, depending on their needs. - -- - -- An example of this style of usage can be found - -- -, _Failure -, _Success - -- * Isomorphisms -, Validate(..) -, revalidate -) where - -import Control.Applicative (Applicative (pure, (<*>)), (<$>)) -import Control.DeepSeq (NFData (rnf)) -import Control.Lens (over, under) -import Control.Lens.Getter ((^.)) -import Control.Lens.Iso (Iso, Swapped (..), from, iso) -import Control.Lens.Prism (Prism, prism) -import Control.Lens.Review (( # )) -import Data.Bifoldable (Bifoldable (bifoldr)) -import Data.Bifunctor (Bifunctor (bimap)) -import Data.Bitraversable (Bitraversable (bitraverse)) -import Data.Data (Data) -import Data.Either (Either (Left, Right), either) -import Data.Eq (Eq) -import Data.Foldable (Foldable (foldr)) -import Data.Function (id, ($), (.)) -import Data.Functor (Functor (fmap)) -import Data.Functor.Alt (Alt (())) -import Data.Functor.Apply (Apply ((<.>))) -import Data.List.NonEmpty (NonEmpty) -import Data.Monoid (Monoid (mappend, mempty)) -import Data.Ord (Ord) -import Data.Semigroup (Semigroup ((<>))) -import Data.Traversable (Traversable (traverse)) -import Data.Typeable (Typeable) -#if __GLASGOW_HASKELL__ >= 702 -import GHC.Generics (Generic) -#endif -import Prelude (Maybe (..), Show) - - --- | An @Validation@ is either a value of the type @err@ or @a@, similar to 'Either'. However, --- the 'Applicative' instance for @Validation@ /accumulates/ errors using a 'Semigroup' on @err@. --- In contrast, the @Applicative@ for @Either@ returns only the first error. --- --- A consequence of this is that @Validation@ has no 'Data.Functor.Bind.Bind' or 'Control.Monad.Monad' instance. This is because --- such an instance would violate the law that a Monad's 'Control.Monad.ap' must equal the --- @Applicative@'s 'Control.Applicative.<*>' --- --- An example of typical usage can be found . --- -data Validation err a = - Failure err - | Success a - deriving ( - Eq, Ord, Show, Data, Typeable -#if __GLASGOW_HASKELL__ >= 702 - , Generic -#endif - ) - -instance Functor (Validation err) where - fmap _ (Failure e) = - Failure e - fmap f (Success a) = - Success (f a) - {-# INLINE fmap #-} - -instance Semigroup err => Apply (Validation err) where - Failure e1 <.> b = Failure $ case b of - Failure e2 -> e1 <> e2 - Success _ -> e1 - Success _ <.> Failure e2 = - Failure e2 - Success f <.> Success a = - Success (f a) - {-# INLINE (<.>) #-} - -instance Semigroup err => Applicative (Validation err) where - pure = - Success - (<*>) = - (<.>) - --- | For two errors, this instance reports only the last of them. -instance Alt (Validation err) where - Failure _ x = - x - Success a _ = - Success a - {-# INLINE () #-} - -instance Foldable (Validation err) where - foldr f x (Success a) = - f a x - foldr _ x (Failure _) = - x - {-# INLINE foldr #-} - -instance Traversable (Validation err) where - traverse f (Success a) = - Success <$> f a - traverse _ (Failure e) = - pure (Failure e) - {-# INLINE traverse #-} - -instance Bifunctor Validation where - bimap f _ (Failure e) = - Failure (f e) - bimap _ g (Success a) = - Success (g a) - {-# INLINE bimap #-} - - -instance Bifoldable Validation where - bifoldr _ g x (Success a) = - g a x - bifoldr f _ x (Failure e) = - f e x - {-# INLINE bifoldr #-} - -instance Bitraversable Validation where - bitraverse _ g (Success a) = - Success <$> g a - bitraverse f _ (Failure e) = - Failure <$> f e - {-# INLINE bitraverse #-} - -appValidation :: - (err -> err -> err) - -> Validation err a - -> Validation err a - -> Validation err a -appValidation m (Failure e1) (Failure e2) = - Failure (e1 `m` e2) -appValidation _ (Failure _) (Success a2) = - Success a2 -appValidation _ (Success a1) (Failure _) = - Success a1 -appValidation _ (Success a1) (Success _) = - Success a1 -{-# INLINE appValidation #-} - -instance Semigroup e => Semigroup (Validation e a) where - (<>) = - appValidation (<>) - {-# INLINE (<>) #-} - -instance Monoid e => Monoid (Validation e a) where - mappend = - appValidation mappend - {-# INLINE mappend #-} - mempty = - Failure mempty - {-# INLINE mempty #-} - -instance Swapped Validation where - swapped = - iso - (\v -> case v of - Failure e -> Success e - Success a -> Failure a) - (\v -> case v of - Failure a -> Success a - Success e -> Failure e) - {-# INLINE swapped #-} - -instance (NFData e, NFData a) => NFData (Validation e a) where - rnf v = - case v of - Failure e -> rnf e - Success a -> rnf a - --- | 'validate's an @a@ producing an updated optional value, returning --- @e@ in the empty case. --- --- This can be thought of as having the less general type: --- --- @ --- validate :: e -> (a -> Maybe b) -> a -> Validation e b --- @ -validate :: Validate v => e -> (a -> Maybe b) -> a -> v e b -validate e p a = case p a of - Nothing -> _Failure # e - Just b -> _Success # b - --- | 'validationNel' is 'liftError' specialised to 'NonEmpty' lists, since --- they are a common semigroup to use. -validationNel :: Either e a -> Validation (NonEmpty e) a -validationNel = liftError pure - --- | Converts from 'Either' to 'Validation'. -fromEither :: Either e a -> Validation e a -fromEither = liftError id - --- | 'liftError' is useful for converting an 'Either' to an 'Validation' --- when the @Left@ of the 'Either' needs to be lifted into a 'Semigroup'. -liftError :: (b -> e) -> Either b a -> Validation e a -liftError f = either (Failure . f) Success - --- | 'validation' is the catamorphism for @Validation@. -validation :: (e -> c) -> (a -> c) -> Validation e a -> c -validation ec ac v = case v of - Failure e -> ec e - Success a -> ac a - --- | Converts from 'Validation' to 'Either'. -toEither :: Validation e a -> Either e a -toEither = validation Left Right - --- | @v 'orElse' a@ returns @a@ when @v@ is Failure, and the @a@ in @Success a@. --- --- This can be thought of as having the less general type: --- --- @ --- orElse :: Validation e a -> a -> a --- @ -orElse :: Validate v => v e a -> a -> a -orElse v a = case v ^. _Validation of - Failure _ -> a - Success x -> x - --- | Return the @a@ or run the given function over the @e@. --- --- This can be thought of as having the less general type: --- --- @ --- valueOr :: (e -> a) -> Validation e a -> a --- @ -valueOr :: Validate v => (e -> a) -> v e a -> a -valueOr ea v = case v ^. _Validation of - Failure e -> ea e - Success a -> a - --- | 'codiagonal' gets the value out of either side. -codiagonal :: Validation a a -> a -codiagonal = valueOr id - --- | 'ensure' ensures that a validation remains unchanged upon failure, --- updating a successful validation with an optional value that could fail --- with @e@ otherwise. --- --- This can be thought of as having the less general type: --- --- @ --- ensure :: e -> (a -> Maybe b) -> Validation e a -> Validation e b --- @ -ensure :: Validate v => e -> (a -> Maybe b) -> v e a -> v e b -ensure e p = - over _Validation $ \v -> case v of - Failure x -> Failure x - Success a -> validate e p a - --- | Run a function on anything with a Validate instance (usually Either) --- as if it were a function on Validation --- --- This can be thought of as having the type --- --- @(Either e a -> Either e' a') -> Validation e a -> Validation e' a'@ -validationed :: Validate v => (v e a -> v e' a') -> Validation e a -> Validation e' a' -validationed f = under _Validation f - --- | @bindValidation@ binds through an Validation, which is useful for --- composing Validations sequentially. Note that despite having a bind --- function of the correct type, Validation is not a monad. --- The reason is, this bind does not accumulate errors, so it does not --- agree with the Applicative instance. --- --- There is nothing wrong with using this function, it just does not make a --- valid @Monad@ instance. -bindValidation :: Validation e a -> (a -> Validation e b) -> Validation e b -bindValidation v f = case v of - Failure e -> Failure e - Success a -> f a - --- | The @Validate@ class carries around witnesses that the type @f@ is isomorphic --- to Validation, and hence isomorphic to Either. -class Validate f where - _Validation :: - Iso (f e a) (f g b) (Validation e a) (Validation g b) - - _Either :: - Iso (f e a) (f g b) (Either e a) (Either g b) - _Either = - iso - (\x -> case x ^. _Validation of - Failure e -> Left e - Success a -> Right a) - (\x -> _Validation # case x of - Left e -> Failure e - Right a -> Success a) - {-# INLINE _Either #-} - -instance Validate Validation where - _Validation = - id - {-# INLINE _Validation #-} - _Either = - iso - (\x -> case x of - Failure e -> Left e - Success a -> Right a) - (\x -> case x of - Left e -> Failure e - Right a -> Success a) - {-# INLINE _Either #-} - -instance Validate Either where - _Validation = - iso - fromEither - toEither - {-# INLINE _Validation #-} - _Either = - id - {-# INLINE _Either #-} - --- | This prism generalises 'Control.Lens.Prism._Left'. It targets the failure case of either 'Either' or 'Validation'. -_Failure :: - Validate f => - Prism (f e1 a) (f e2 a) e1 e2 -_Failure = - prism - (\x -> _Either # Left x) - (\x -> case x ^. _Either of - Left e -> Right e - Right a -> Left (_Either # Right a)) -{-# INLINE _Failure #-} - --- | This prism generalises 'Control.Lens.Prism._Right'. It targets the success case of either 'Either' or 'Validation'. -_Success :: - Validate f => - Prism (f e a) (f e b) a b -_Success = - prism - (\x -> _Either # Right x) - (\x -> case x ^. _Either of - Left e -> Left (_Either # Left e) - Right a -> Right a) -{-# INLINE _Success #-} - --- | 'revalidate' converts between any two instances of 'Validate'. -revalidate :: (Validate f, Validate g) => Iso (f e1 s) (f e2 t) (g e1 s) (g e2 t) -revalidate = _Validation . from _Validation diff --git a/validation/validation.cabal b/validation/validation.cabal deleted file mode 100644 index 60e5444..0000000 --- a/validation/validation.cabal +++ /dev/null @@ -1,23 +0,0 @@ -name: validation -version: 1 -license: BSD3 -license-file: LICENSE -author: Tony Morris <ʇǝu˙sıɹɹoɯʇ@ןןǝʞsɐɥ> , Nick Partridge -maintainer: Tony Morris <ʇǝu˙sıɹɹoɯʇ@ןןǝʞsɐɥ> , Nick Partridge , Queensland Functional Programming Lab -synopsis: A data-type like Either but with an accumulating Applicative -category: Data -cabal-version: >= 1.10 -build-type: Simple - -library - Default-Language: Haskell2010 - Build-Depends: - base >= 4.5 && < 5 - , deepseq >= 1.2 && < 1.5 - , semigroups >= 0.8 && < 1 - , semigroupoids >= 5 && < 6 - , bifunctors >= 5.1 && < 6 - , lens >= 4 && < 5 - Ghc-Options: -Wall - Hs-Source-Dirs: src - Exposed-Modules: Data.Validation -- cgit v1.2.3