mtg-lets-trade

Website/webapp to facilitate trading between players of Magic: The Gathering
git clone https://kevincorvisier.fr/git/mtg-lets-trade.git
Log | Files | Refs

commit 18888181031225e7ccfebc549e3f7e1ae2baed37
parent fa8656adaa20794463ba2b0895d4e7537e7889f1
Author: Kevin Corvisier <git@kevincorvisier.fr>
Date:   Thu, 10 Apr 2025 08:02:02 +0900

Binders pages, ManaBox collection import

Diffstat:
Mpackage-lock.json | 524++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackage.json | 10++++++++--
Mpom.xml | 34++++++++++++++++++++++++++++++++++
Asrc/main/frontend/AuthenticatedLayout.tsx | 14++++++++++++++
Asrc/main/frontend/api/hooks/binders.hook.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/frontend/api/types/BinderCardDTO.ts | 7+++++++
Asrc/main/frontend/api/types/BinderDTO.ts | 5+++++
Asrc/main/frontend/api/types/CardFinish.ts | 6++++++
Asrc/main/frontend/api/types/CardPrintDTO.ts | 13+++++++++++++
Asrc/main/frontend/api/types/OracleCardDTO.ts | 4++++
Asrc/main/frontend/api/types/Page.ts | 22++++++++++++++++++++++
Asrc/main/frontend/api/types/SetDTO.ts | 5+++++
Asrc/main/frontend/app.scss | 2++
Asrc/main/frontend/components/mutation/MutationButton.tsx | 22++++++++++++++++++++++
Asrc/main/frontend/components/navigation/AuthenticatedNavBar.tsx | 30++++++++++++++++++++++++++++++
Asrc/main/frontend/components/navigation/Links.tsx | 17+++++++++++++++++
Asrc/main/frontend/components/table/ColumnDef.ts | 12++++++++++++
Asrc/main/frontend/components/table/DataTableBody.tsx | 29+++++++++++++++++++++++++++++
Asrc/main/frontend/components/table/DataTablePage.tsx | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/frontend/components/table/PageableQueryDataTable.tsx | 34++++++++++++++++++++++++++++++++++
Msrc/main/frontend/main.tsx | 46++++++++++++++++++++++++++++++++++++----------
Msrc/main/frontend/pages/LoginForm.tsx | 10++++++----
Asrc/main/frontend/pages/binders/BinderPage.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/frontend/pages/binders/BindersPage.tsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/frontend/pages/binders/CollectionImportModal.tsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main/frontend/pages/dashboard/DashboardPage.tsx | 9---------
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/SecurityConfig.java | 17++++++++++++-----
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/BindersController.java | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/BinderCardDTO.java | 12++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/BinderDTO.java | 19+++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/CardPrintDTO.java | 17+++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/OracleCardDTO.java | 11+++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/SetDTO.java | 12++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/BinderCardMapper.java | 13+++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/BinderMapper.java | 22++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/CardPrintMapper.java | 13+++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/OracleCardMapper.java | 13+++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/SetMapper.java | 13+++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/database/DatabaseConfiguration.java | 6++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/embeddable/BinderCardId.java | 29+++++++++++++++++++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBBinder.java | 10++++++++--
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBBinderCard.java | 21+++++++++++++++------
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBCardPrint.java | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBPrintedCard.java | 78------------------------------------------------------------------------------
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/exceptions/BinderNotFoundException.java | 15+++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/providers/DBBinderProvider.java | 22++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBBinderCardRepository.java | 15+++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBBinderRepository.java | 20++++++++++++++++++++
Dsrc/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBCardRepository.java | 15---------------
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBPrintedCardRepository.java | 21+++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionCsvReader.java | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionEntry.java | 39+++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionImporter.java | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxFieldMissingException.java | 16++++++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/ScryfallRestClient.java | 52++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/domain/ScryfallCard.java | 6++++++
Asrc/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/domain/ScryfallCardFace.java | 27+++++++++++++++++++++++++++
Msrc/main/java/fr/kevincorvisier/mtg/letstrade/tasks/ScryfallSynchronizationTask.java | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/main/resources/application.properties | 2+-
Msrc/main/resources/db/migration/V0_0_1.sql | 18++++++++++++++++++
Mwebpack.common.js | 15+++++++++++++--
61 files changed, 2011 insertions(+), 175 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -9,21 +9,27 @@ "version": "0.0.1-SNAPSHOT", "dependencies": { "@tanstack/react-query": "^5.69.0", + "@tanstack/react-table": "^8.21.2", "axios": "^1.8.4", "bootstrap": "^5.3.3", + "immutability-helper": "^3.1.1", "react": "^19.0.0", "react-bootstrap": "^2.10.9", "react-dom": "^19.0.0", - "react-router": "^7.4.0" + "react-router": "^7.4.0", + "react-router-bootstrap": "^0.26.3" }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.68.0", "@tsconfig/strictest": "^2.0.5", "@types/react-dom": "^19.0.4", + "@types/react-router-bootstrap": "^0.26.6", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.2", "html-webpack-plugin": "^5.6.3", "mini-css-extract-plugin": "^2.9.2", + "sass": "^1.86.3", + "sass-loader": "^16.0.5", "ts-loader": "^9.5.2", "typescript": "^5.8.2", "webpack": "^5.98.0", @@ -468,6 +474,316 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -606,6 +922,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz", + "integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", + "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -734,6 +1083,16 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-router-bootstrap": { + "version": "0.26.6", + "resolved": "https://registry.npmjs.org/@types/react-router-bootstrap/-/react-router-bootstrap-0.26.6.tgz", + "integrity": "sha512-uqDwCdHp/qkXUPd8iOgZ92HQnTfyjpIwqgvbenW5SejrN3l6bkcObZQ/kkirHk39AQK4MJVpQBvcSu/SE6Crnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", @@ -1420,6 +1779,22 @@ "node": ">=8" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -1897,6 +2272,20 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -2953,6 +3342,19 @@ "node": ">= 4" } }, + "node_modules/immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==", + "license": "MIT" + }, + "node_modules/immutable": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz", + "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3474,6 +3876,14 @@ "tslib": "^2.0.3" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -4423,9 +4833,9 @@ "license": "MIT" }, "node_modules/react-router": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.4.0.tgz", - "integrity": "sha512-Y2g5ObjkvX3VFeVt+0CIPuYd9PpgqCslG7ASSIdN73LwA1nNWzcMLaoMRJfP3prZFI92svxFwbn7XkLJ+UPQ6A==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz", + "integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==", "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", @@ -4446,6 +4856,36 @@ } } }, + "node_modules/react-router-bootstrap": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/react-router-bootstrap/-/react-router-bootstrap-0.26.3.tgz", + "integrity": "sha512-cBgcWekti6lFRl/vXP8ZfKuA/0Qe7L5xBjQ6OwbGI30+NSAAH/YZGbO6whSeBWFILn6uTVOX939HDGhs+5WzOw==", + "license": "Apache-2.0", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.13.1", + "react-router-dom": ">=6.0.0" + } + }, + "node_modules/react-router-dom": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.0.tgz", + "integrity": "sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA==", + "license": "MIT", + "peer": true, + "dependencies": { + "react-router": "7.5.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4462,6 +4902,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -4615,6 +5069,68 @@ ], "license": "MIT" }, + "node_modules/sass": { + "version": "1.86.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz", + "integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", diff --git a/package.json b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "webpack --config webpack.dev.js", + "dev": "webpack --config webpack.dev.js --watch", "build": "webpack --config webpack.prod.js" }, "author": "", @@ -12,21 +12,27 @@ "description": "", "dependencies": { "@tanstack/react-query": "^5.69.0", + "@tanstack/react-table": "^8.21.2", "axios": "^1.8.4", "bootstrap": "^5.3.3", + "immutability-helper": "^3.1.1", "react": "^19.0.0", "react-bootstrap": "^2.10.9", "react-dom": "^19.0.0", - "react-router": "^7.4.0" + "react-router": "^7.4.0", + "react-router-bootstrap": "^0.26.3" }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.68.0", "@tsconfig/strictest": "^2.0.5", "@types/react-dom": "^19.0.4", + "@types/react-router-bootstrap": "^0.26.6", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.2", "html-webpack-plugin": "^5.6.3", "mini-css-extract-plugin": "^2.9.2", + "sass": "^1.86.3", + "sass-loader": "^16.0.5", "ts-loader": "^9.5.2", "typescript": "^5.8.2", "webpack": "^5.98.0", diff --git a/pom.xml b/pom.xml @@ -14,6 +14,13 @@ <artifactId>lets-trade</artifactId> <version>0.0.1-SNAPSHOT</version> + <properties> + <m2e.apt.activation>jdt_apt</m2e.apt.activation> + + <org.mapstruct.version>1.6.3</org.mapstruct.version> + <org.mapstruct.extensions.spring.version>1.1.3</org.mapstruct.extensions.spring.version> + </properties> + <dependencies> <dependency> <groupId>org.springframework.boot</groupId> @@ -32,6 +39,23 @@ <artifactId>spring-boot-starter-web</artifactId> </dependency> + <dependency> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct</artifactId> + <version>${org.mapstruct.version}</version> + </dependency> + <dependency> + <groupId>org.mapstruct.extensions.spring</groupId> + <artifactId>mapstruct-spring-annotations</artifactId> + <version>${org.mapstruct.extensions.spring.version}</version> + </dependency> + + <dependency> + <groupId>com.opencsv</groupId> + <artifactId>opencsv</artifactId> + <version>5.10</version> + </dependency> + <!-- SQLite database --> <dependency> <groupId>org.flywaydb</groupId> @@ -64,6 +88,16 @@ <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </path> + <path> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct-processor</artifactId> + <version>${org.mapstruct.version}</version> + </path> + <path> + <groupId>org.mapstruct.extensions.spring</groupId> + <artifactId>mapstruct-spring-extensions</artifactId> + <version>${org.mapstruct.extensions.spring.version}</version> + </path> </annotationProcessorPaths> </configuration> </plugin> diff --git a/src/main/frontend/AuthenticatedLayout.tsx b/src/main/frontend/AuthenticatedLayout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router"; +import AuthenticatedNavBar from "./components/navigation/AuthenticatedNavBar"; + +export default function AuthenticatedLayout() { + + return ( + <> + <AuthenticatedNavBar /> + + <Outlet /> + </> + ); +} +\ No newline at end of file diff --git a/src/main/frontend/api/hooks/binders.hook.ts b/src/main/frontend/api/hooks/binders.hook.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { BinderDTO } from "../types/BinderDTO"; +import { Page, Pageable } from "../types/Page"; +import { BinderCardDTO } from "../types/BinderCardDTO"; +import { queryClient } from "../../main"; + +const BASE_URI: string = "/api/binders"; + +export const getBinders = (pageable: Pageable) => { + return useQuery<Page<BinderDTO>, string>({ + queryKey: ["binders", pageable], + queryFn: () => axios.get<Page<BinderDTO>>(BASE_URI, { params: { ...pageable } }).then(response => response.data) + }); +}; + +export const getBinder = (id: string) => { + return useQuery<BinderDTO, string>({ + queryKey: ["binder", id], + queryFn: () => axios.get<BinderDTO>(`${BASE_URI}/${id}`).then(response => response.data) + }); +}; + +export const getBinderCards = (id: string, pageable: Pageable) => { + return useQuery<Page<BinderCardDTO>, string>({ + queryKey: ["binder-cards", id, pageable], + queryFn: () => axios.get<Page<BinderCardDTO>>(`${BASE_URI}/${id}/cards`, { params: { ...pageable } }).then(response => response.data) + }); +}; + + +export const useImportCollection = () => { + return useMutation<void, string, File>({ + mutationFn: (file: File) => { + const data = new FormData(); + data.append("file", file); + return axios.post(BASE_URI, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['binders'] }); + queryClient.invalidateQueries({ queryKey: ['binder'] }); + queryClient.invalidateQueries({ queryKey: ['binder-cards'] }); + } + }); +}; diff --git a/src/main/frontend/api/types/BinderCardDTO.ts b/src/main/frontend/api/types/BinderCardDTO.ts @@ -0,0 +1,6 @@ +import { CardPrintDTO } from "./CardPrintDTO"; + +export type BinderCardDTO = { + card: CardPrintDTO, + quantity: number +}; +\ No newline at end of file diff --git a/src/main/frontend/api/types/BinderDTO.ts b/src/main/frontend/api/types/BinderDTO.ts @@ -0,0 +1,4 @@ +export type BinderDTO = { + id: number, + name: string +}; +\ No newline at end of file diff --git a/src/main/frontend/api/types/CardFinish.ts b/src/main/frontend/api/types/CardFinish.ts @@ -0,0 +1,5 @@ +export enum CardFinish { + NONFOIL, + FOIL, + ETCHED +} +\ No newline at end of file diff --git a/src/main/frontend/api/types/CardPrintDTO.ts b/src/main/frontend/api/types/CardPrintDTO.ts @@ -0,0 +1,12 @@ +import { CardFinish } from "./CardFinish"; +import { OracleCardDTO } from "./OracleCardDTO"; +import { SetDTO } from "./SetDTO"; + +export type CardPrintDTO = { + id: number, + oracleCard: OracleCardDTO, + set: SetDTO, + lang: string, + finish: CardFinish, + collectorNumber: string +}; +\ No newline at end of file diff --git a/src/main/frontend/api/types/OracleCardDTO.ts b/src/main/frontend/api/types/OracleCardDTO.ts @@ -0,0 +1,3 @@ +export type OracleCardDTO = { + name: string +}; +\ No newline at end of file diff --git a/src/main/frontend/api/types/Page.ts b/src/main/frontend/api/types/Page.ts @@ -0,0 +1,21 @@ +// From org.springframework.data.domain.Page<T> +export type Page<T> = { + content: T[], + totalPages: number, + totalElements: number, + size: number, + number: number +}; + +// See: https://docs.spring.io/spring-data/rest/reference/paging-and-sorting.html +export type Pageable = { + page?: number | undefined, + size?: number | undefined, + sort?: string[] +}; + +export const PAGEABLE_DEFAULT: Pageable = { + page: 0, + size: 25 +} +export const UNPAGED: Pageable = {}; +\ No newline at end of file diff --git a/src/main/frontend/api/types/SetDTO.ts b/src/main/frontend/api/types/SetDTO.ts @@ -0,0 +1,4 @@ +export type SetDTO = { + code: string, + name: string +}; +\ No newline at end of file diff --git a/src/main/frontend/app.scss b/src/main/frontend/app.scss @@ -0,0 +1 @@ +@use "~bootstrap/scss/bootstrap"; +\ No newline at end of file diff --git a/src/main/frontend/components/mutation/MutationButton.tsx b/src/main/frontend/components/mutation/MutationButton.tsx @@ -0,0 +1,22 @@ +import { UseMutationResult } from "@tanstack/react-query"; +import Spinner from "react-bootstrap/Spinner"; +import Button, { ButtonProps } from "react-bootstrap/Button"; + +interface Props extends Omit<ButtonProps, 'children' | 'as'> { + mutation: UseMutationResult<any, any, any, any>, + label: string, + loadingLabel: string +}; + +export default function MutationButton({ mutation, label, loadingLabel, ...props }: Props) { + if (mutation.isPending) { + return ( + <Button {...props}><Spinner animation="border" size="sm" role="status" aria-hidden="true" />{loadingLabel}</Button> + ); + } + else { + return ( + <Button {...props}>{label}</Button> + ); + } +}; diff --git a/src/main/frontend/components/navigation/AuthenticatedNavBar.tsx b/src/main/frontend/components/navigation/AuthenticatedNavBar.tsx @@ -0,0 +1,29 @@ +import Navbar from "react-bootstrap/Navbar"; +import Nav from "react-bootstrap/Nav"; +import { LinkContainer } from "react-router-bootstrap" +import { useLogout } from "../../api/hooks/auth.hooks"; + +export default function AuthenticatedNavBar() { + const logoutMutation = useLogout(); + + const handleLogout = () => { + logoutMutation.mutate() + }; + + return ( + <Navbar bg="dark" data-bs-theme="dark"> + <Nav> + <Nav.Item> + <LinkContainer to="/binders"> + <Nav.Link>Binders</Nav.Link> + </LinkContainer> + </Nav.Item> + </Nav> + <Nav className="ms-auto"> + <Nav.Item> + <Nav.Link onClick={handleLogout}>Logout</Nav.Link> + </Nav.Item> + </Nav> + </Navbar> + ); +} +\ No newline at end of file diff --git a/src/main/frontend/components/navigation/Links.tsx b/src/main/frontend/components/navigation/Links.tsx @@ -0,0 +1,16 @@ +import { NavLink, NavLinkProps } from "react-router"; +import { BinderDTO } from "../../api/types/BinderDTO"; + +export function BindersLink({ ...props }: Readonly<Omit<NavLinkProps, 'to'>>) { + return <NavLink {...props} to="/binders">Binders</NavLink> +}; + +interface BinderLinkProps extends Omit<NavLinkProps, 'to'> { + binder: BinderDTO +}; + +export function BinderLink({ binder, ...props }: Readonly<BinderLinkProps>) { + return ( + <NavLink {...props} to={`/binders/${binder.id}`}>{binder.name}</NavLink> + ); +}; +\ No newline at end of file diff --git a/src/main/frontend/components/table/ColumnDef.ts b/src/main/frontend/components/table/ColumnDef.ts @@ -0,0 +1,11 @@ +import { ReactNode } from "react"; + +export type ColumnDef<T> = { + key: React.Key, + header: string, + render: (row: T) => ReactNode +}; + +export type RowDef<T> = { + key: (row: T) => React.Key +}; +\ No newline at end of file diff --git a/src/main/frontend/components/table/DataTableBody.tsx b/src/main/frontend/components/table/DataTableBody.tsx @@ -0,0 +1,29 @@ +import { ColumnDef, RowDef } from "./ColumnDef"; + +type Props<T> = { + data: T[], + columns: ColumnDef<T>[], + rows: RowDef<T> +}; + +export default function DataTableBody<T>({ data, rows, columns }: Props<T>) { + return ( + <tbody> + {data.flatMap(row => { + return ( + <tr key={rows.key(row)}> + + {columns.flatMap(column => { + return ( + <td key={column.key}> + {column.render(row)} + </td> + ) + })} + + </tr> + ) + })} + </tbody> + ) +}; diff --git a/src/main/frontend/components/table/DataTablePage.tsx b/src/main/frontend/components/table/DataTablePage.tsx @@ -0,0 +1,74 @@ +import { Page, Pageable } from "../../api/types/Page"; +import Table from "react-bootstrap/Table"; +import DataTableBody from "./DataTableBody"; +import { ColumnDef, RowDef } from "./ColumnDef"; +import Pagination from "react-bootstrap/Pagination"; +import update from "immutability-helper"; +import { ChangeEvent } from "react"; + +type Props<T> = { + page: Page<T>, + columns: ColumnDef<T>[], + rows: RowDef<T>, + pageable: Pageable, + onPageableChange: (pageable: Pageable) => void +}; + +export default function DataTablePage<T>({ page, columns, rows, pageable, onPageableChange }: Props<T>) { + const start = page.number * page.size; + const end = Math.min((page.number + 1) * page.size, page.totalElements); + + const firstPage: number = 0; + const lastPage: number = Math.floor(page.totalElements / page.size); + + const handlePageSizeChange = (event: ChangeEvent<HTMLSelectElement>): void => { + onPageableChange(update(pageable, { + size: { $set: parseInt(event.target.value) }, + page: { $set: 0 } + })); + } + const setPage = (pageNumber: number): void => { + onPageableChange(update(pageable, { page: { $set: pageNumber } })); + }; + + const listPages: React.JSX.Element[] = []; + if (firstPage != lastPage) { + for (let pageNumber: number = firstPage; pageNumber <= lastPage; pageNumber++) { + listPages.push(<Pagination.Item key={pageNumber} active={pageNumber == page.number} onClick={() => setPage(pageNumber)}>{pageNumber}</Pagination.Item>); + } + } + + return ( + <> + <div> + Show <select value={pageable.size} onChange={handlePageSizeChange}> + <option value="10">10</option> + <option value="25">25</option> + <option value="50">50</option> + <option value="100">100</option> + </select> entries + </div> + + <Table> + <thead> + <tr> + {columns.map(column => ( + <td key={column.key}> + {column.header} + </td> + ))} + </tr> + </thead> + <DataTableBody data={page.content} columns={columns} rows={rows} /> + <tfoot> + <tr> + <td colSpan={columns.length}> + <Pagination size="sm" className="float-end mb-0">{listPages}</Pagination> + {start + 1}-{end} of {page.totalElements} + </td> + </tr> + </tfoot> + </Table> + </> + ); +} +\ No newline at end of file diff --git a/src/main/frontend/components/table/PageableQueryDataTable.tsx b/src/main/frontend/components/table/PageableQueryDataTable.tsx @@ -0,0 +1,33 @@ +import { UseQueryResult } from "@tanstack/react-query"; +import { Page, Pageable, PAGEABLE_DEFAULT } from "../../api/types/Page"; +import DataTablePage from "./DataTablePage"; +import { ColumnDef, RowDef } from "./ColumnDef"; +import { useState } from "react"; + +type Props<T> = { + query: (pageable: Pageable) => UseQueryResult<Page<T>, string>, + columns: ColumnDef<T>[], + rows: RowDef<T>, +}; + +export default function PageableQueryDataTable<T>({ query, columns, rows }: Props<T>) { + const [pageable, setPageable] = useState<Pageable>(PAGEABLE_DEFAULT); + const { data, isFetching, isError, error } = query(pageable); + + return ( + <div style={{ position: "relative" }}> + {isFetching && ( + <div style={{ position: "absolute", backgroundColor: "black", color: "white" }}>loading...</div> + )} + {isError && ( + <div style={{ position: "absolute", backgroundColor: "red", color: "white" }}>{error}</div> + )} + <DataTablePage + page={data ?? { content: [], number: 0, size: 0, totalPages: 0, totalElements: 0 }} + columns={columns} + rows={rows} + pageable={pageable} onPageableChange={setPageable} + /> + </div> + ) +}; +\ No newline at end of file diff --git a/src/main/frontend/main.tsx b/src/main/frontend/main.tsx @@ -1,15 +1,17 @@ -import React from "react"; +import React, { StrictMode } from "react"; import { BrowserRouter, Route, Routes } from "react-router"; import { createRoot } from "react-dom/client"; - -import 'bootstrap/dist/css/bootstrap.min.css'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ProtectedRoute from "./ProtectedRoute"; import { AuthenticationContextProvider, useAuthentication } from "./authentication/AuthenticationContext"; +import AuthenticatedLayout from "./AuthenticatedLayout"; + +import "./app.scss"; const HomePage = React.lazy(() => import("./pages/HomePage")); const DashboardPage = React.lazy(() => import("./pages/dashboard/DashboardPage")); - +const BindersPage = React.lazy(() => import("./pages/binders/BindersPage")); +const BinderPage = React.lazy(() => import("./pages/binders/BinderPage")); const AppRouter = () => { const username = useAuthentication(); @@ -21,7 +23,11 @@ const AppRouter = () => { <Route path="/login" element={<HomePage />} /> </Route> <Route element={<ProtectedRoute isAllowed={!!username} redirectPath="/login" />}> - <Route path="/" element={<DashboardPage />} /> + <Route element={<AuthenticatedLayout />}> + <Route path="/" element={<DashboardPage />} /> + <Route path="/binders" element={<BindersPage />} /> + <Route path="/binders/:id" element={<BinderPage />} /> + </Route> </Route> </Routes> </BrowserRouter> @@ -31,13 +37,33 @@ const AppRouter = () => { export const queryClient = new QueryClient(); const App = () => ( - <QueryClientProvider client={queryClient}> - <AuthenticationContextProvider> - <AppRouter /> - </AuthenticationContextProvider> - </QueryClientProvider> + <StrictMode> + <QueryClientProvider client={queryClient}> + <AuthenticationContextProvider> + <AppRouter /> + </AuthenticationContextProvider> + </QueryClientProvider> + </StrictMode> ); +/* + * Bootstrap color modes + */ + +// Set theme to the user's preferred color scheme +function updateTheme() { + const colorMode = window.matchMedia("(prefers-color-scheme: dark)").matches ? + "dark" : + "light"; + document.querySelector("html")?.setAttribute("data-bs-theme", colorMode); + } + + // Set theme on load + updateTheme() + + // Update theme when the preferred scheme changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme) + const container = document.getElementById("root"); if (container != null) { const root = createRoot(container); diff --git a/src/main/frontend/pages/LoginForm.tsx b/src/main/frontend/pages/LoginForm.tsx @@ -1,7 +1,7 @@ -import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; import { useLogin } from "../api/hooks/auth.hooks"; import { FormEvent, useState } from "react"; +import MutationButton from "../components/mutation/MutationButton"; export default function LoginForm() { const [login, setLogin] = useState<string>(""); @@ -26,9 +26,11 @@ export default function LoginForm() { <Form.Control type="password" value={password} onChange={e => setPassword(e.target.value)} /> </Form.Group> - <Button variant="primary" type="submit"> - Login - </Button> + <MutationButton + mutation={loginMutation} + label="Login" loadingLabel="Login in..." + variant="primary" type="submit" + /> </Form> ); } \ No newline at end of file diff --git a/src/main/frontend/pages/binders/BinderPage.tsx b/src/main/frontend/pages/binders/BinderPage.tsx @@ -0,0 +1,70 @@ +import { getBinder, getBinderCards } from "../../api/hooks/binders.hook"; +import PageableQueryDataTable from "../../components/table/PageableQueryDataTable"; +import { ColumnDef, RowDef } from "../../components/table/ColumnDef"; +import { useParams } from "react-router"; +import { BinderCardDTO } from "../../api/types/BinderCardDTO"; +import Breadcrumb from "react-bootstrap/Breadcrumb"; +import { BinderLink, BindersLink } from "../../components/navigation/Links"; + +type Params = { + id: string +}; + +const rows: RowDef<BinderCardDTO> = { + key: (b: BinderCardDTO) => b.card.id +} + +const columns: ColumnDef<BinderCardDTO>[] = [ + { + key: "name", + header: "Name", + render: (b: BinderCardDTO) => b.card.oracleCard.name + }, + { + key: "set", + header: "Set", + render: (b: BinderCardDTO) => b.card.set.code.toUpperCase() + }, + { + key: "finish", + header: "Finish", + render: (b: BinderCardDTO) => b.card.finish + }, + { + key: "collectorNumber", + header: "Collector Number", + render: (b: BinderCardDTO) => b.card.collectorNumber + }, + { + key: "quantity", + header: "Qty", + render: (b: BinderCardDTO) => b.quantity + } +] + +export default function BinderPage() { + const params: Readonly<Partial<Params>> = useParams<Params>(); + if (params.id === undefined) + throw new Error("Binder id not present"); + + const binderId = params.id; + + const { data: binder } = getBinder(binderId); + if (binder == null) + return null; + + return ( + <> + <Breadcrumb> + <Breadcrumb.Item linkAs="span"><BindersLink /></Breadcrumb.Item> + <Breadcrumb.Item linkAs="span" active><BinderLink binder={binder} /></Breadcrumb.Item> + </Breadcrumb> + + <PageableQueryDataTable + query={(pageable) => getBinderCards(binderId, pageable)} + columns={columns} + rows={rows} + /> + </> + ); +} +\ No newline at end of file diff --git a/src/main/frontend/pages/binders/BindersPage.tsx b/src/main/frontend/pages/binders/BindersPage.tsx @@ -0,0 +1,49 @@ +import { getBinders } from "../../api/hooks/binders.hook"; +import { BinderDTO } from "../../api/types/BinderDTO"; +import PageableQueryDataTable from "../../components/table/PageableQueryDataTable"; +import { ColumnDef, RowDef } from "../../components/table/ColumnDef"; +import { BinderLink, BindersLink } from "../../components/navigation/Links"; +import Breadcrumb from "react-bootstrap/Breadcrumb"; +import Button from "react-bootstrap/Button"; +import { useState } from "react"; +import CollectionImportModal from "./CollectionImportModal"; + +const rows: RowDef<BinderDTO> = { + key: (b: BinderDTO) => b.id +} + +const columns: ColumnDef<BinderDTO>[] = [ + { + key: "name", + header: "Name", + render: (b: BinderDTO) => <BinderLink binder={b} /> + } +] + +export default function BindersPage() { + const [modal, setModal] = useState(false); + + return ( + <> + <Breadcrumb> + <Breadcrumb.Item linkAs="span" active><BindersLink /></Breadcrumb.Item> + + <li className="ms-auto"> + <Button size="sm" variant="secondary" onClick={() => setModal(true)} > + Import + </Button> + </li> + </Breadcrumb> + + <PageableQueryDataTable + query={(pageable) => getBinders(pageable)} + columns={columns} + rows={rows} + /> + + {modal && ( + <CollectionImportModal onHide={() => setModal(false)} /> + )} + </> + ); +} +\ No newline at end of file diff --git a/src/main/frontend/pages/binders/CollectionImportModal.tsx b/src/main/frontend/pages/binders/CollectionImportModal.tsx @@ -0,0 +1,68 @@ +import { FormEvent, useState } from "react"; +import Form from "react-bootstrap/Form"; +import Button from "react-bootstrap/Button"; +import Modal from "react-bootstrap/Modal"; +import { useImportCollection } from "../../api/hooks/binders.hook"; +import MutationButton from "../../components/mutation/MutationButton"; + +type Props = { + onHide: () => void +} + +export default function CollectionImportModal({ onHide }: Props) { + const [file, setFile] = useState<File | undefined>(undefined); + const mutation = useImportCollection(); + + const disabled = file == undefined; + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const files = e.currentTarget.files + if (files && files.length >= 1) + setFile(files[0]) + } + + const handleSubmit = (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + + if (!disabled) + mutation.mutate(file, { onSuccess: onHide }) + }; + + return ( + <Modal show onHide={onHide}> + <Form onSubmit={handleSubmit}> + <Modal.Header closeButton> + <Modal.Title>Importing your collection</Modal.Title> + </Modal.Header> + + <Modal.Body> + <Form.Group> + <Form.Label>File</Form.Label> + <Form.Control required + type="file" accept=".csv" + onChange={handleFileChange} + /> + <Form.Text muted> + Supported formats: + <ul> + <li>ManaBox collection export</li> + </ul> + </Form.Text> + </Form.Group> + </Modal.Body> + + <Modal.Footer> + <MutationButton + mutation={mutation} + label="Import" loadingLabel="Importing..." + type="submit" disabled={disabled} + /> + + <Button variant="secondary" onClick={onHide}> + Close + </Button> + </Modal.Footer> + </Form> + </Modal> + ); +} +\ No newline at end of file diff --git a/src/main/frontend/pages/dashboard/DashboardPage.tsx b/src/main/frontend/pages/dashboard/DashboardPage.tsx @@ -1,18 +1,9 @@ -import { Button } from "react-bootstrap"; -import { useLogout } from "../../api/hooks/auth.hooks"; export default function DashboardPage() { - const logoutMutation = useLogout(); - - const handleLogout = () => { - logoutMutation.mutate() - }; return ( <> <p>Authenticated</p> - - <Button variant="primary" onClick={handleLogout}>Logout</Button> </> ); } \ No newline at end of file diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/SecurityConfig.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -21,12 +22,18 @@ public class SecurityConfig @Bean public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { - http.csrf(csrf -> csrf.disable()) // - .logout(logout -> logout.logoutUrl("/api/authentication/logout").logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())) // + return http // + .csrf(CsrfConfigurer::disable) // + .logout(logout -> logout // + .logoutUrl("/api/authentication/logout") // + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()) // + ) // .authorizeHttpRequests(authorize -> authorize // - .anyRequest().permitAll()); - - return http.build(); + .requestMatchers("/api/authentication/**").permitAll() // + .requestMatchers("/api/**").authenticated() // + .anyRequest().permitAll() // + ) // + .build(); } @Bean diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/BindersController.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/BindersController.java @@ -0,0 +1,92 @@ +package fr.kevincorvisier.mtg.letstrade.controllers; + +import java.io.IOException; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import fr.kevincorvisier.mtg.letstrade.controllers.dto.BinderCardDTO; +import fr.kevincorvisier.mtg.letstrade.controllers.dto.BinderDTO; +import fr.kevincorvisier.mtg.letstrade.controllers.mappers.BinderCardMapper; +import fr.kevincorvisier.mtg.letstrade.controllers.mappers.BinderMapper; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinder; +import fr.kevincorvisier.mtg.letstrade.database.exceptions.BinderNotFoundException; +import fr.kevincorvisier.mtg.letstrade.database.providers.DBBinderProvider; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBBinderCardRepository; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBBinderRepository; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBUserRepository; +import fr.kevincorvisier.mtg.letstrade.manabox.ManaBoxCollectionImporter; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/binders") +@RequiredArgsConstructor +public class BindersController +{ + private final DBBinderRepository binderRepository; + private final DBBinderProvider binderProvider; + private final DBBinderCardRepository binderCardRepository; + private final DBUserRepository userRepository; + private final ManaBoxCollectionImporter importer; + private final BinderMapper mapper; + private final BinderCardMapper cardMapper; + + @GetMapping + @Nullable + public Page<BinderDTO> getBinders( // + @AuthenticationPrincipal @NonNull @NotNull final User user, // + @NonNull @NotNull final Pageable pageable) + { + final String username = user.getUsername(); + if (username == null) + throw new NullPointerException(); + return binderRepository.findAllByOwnerLogin(username, pageable).map(mapper::toDto); + } + + @GetMapping("/{id}") + @Nullable + public BinderDTO getBinders( // + @AuthenticationPrincipal @NonNull @NotNull final User user, // + @PathVariable final long id) throws BinderNotFoundException + { + final String username = user.getUsername(); + if (username == null) + throw new NullPointerException(); + + return mapper.toDto(binderProvider.findByIdOrThrow(id)); + } + + @GetMapping("/{id}/cards") + public Page<BinderCardDTO> getBinderCards( // + @AuthenticationPrincipal @NonNull @NotNull final User user, // + @PathVariable final long id, // + @NonNull @NotNull final Pageable pageable // + ) throws BinderNotFoundException + { + final DBBinder binder = binderProvider.findByIdOrThrow(id); + return binderCardRepository.findAllByBinder(binder, pageable).map(cardMapper::toDto); + } + + @PostMapping + public void importCollection( // + @AuthenticationPrincipal @NonNull @NotNull final User user, // + @RequestBody final MultipartFile file // + ) throws IOException + { + importer.test(userRepository.findByLogin(user.getUsername()).orElseThrow(), file.getBytes()); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/BinderCardDTO.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/BinderCardDTO.java @@ -0,0 +1,12 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class BinderCardDTO +{ + private final CardPrintDTO card; + private final int quantity; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/BinderDTO.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/BinderDTO.java @@ -0,0 +1,19 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Data +@Builder +public class BinderDTO +{ + private final Long id; + + @NonNull + @NotNull + private final String name; + + private final int nbCards; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/CardPrintDTO.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/CardPrintDTO.java @@ -0,0 +1,17 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.dto; + +import fr.kevincorvisier.mtg.letstrade.database.CardFinish; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CardPrintDTO +{ + private final Long id; + private final OracleCardDTO oracleCard; + private final SetDTO set; + private final String lang; + private final CardFinish finish; + private final String collectorNumber; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/OracleCardDTO.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/OracleCardDTO.java @@ -0,0 +1,11 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class OracleCardDTO +{ + private final String name; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/SetDTO.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/dto/SetDTO.java @@ -0,0 +1,12 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SetDTO +{ + private final String code; + private final String name; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/BinderCardMapper.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/BinderCardMapper.java @@ -0,0 +1,13 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.mappers; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants.ComponentModel; + +import fr.kevincorvisier.mtg.letstrade.controllers.dto.BinderCardDTO; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinderCard; + +@Mapper(componentModel = ComponentModel.SPRING) +public interface BinderCardMapper +{ + BinderCardDTO toDto(final DBBinderCard card); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/BinderMapper.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/BinderMapper.java @@ -0,0 +1,22 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.mappers; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants.ComponentModel; +import org.mapstruct.MappingTarget; + +import fr.kevincorvisier.mtg.letstrade.controllers.dto.BinderDTO; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinder; + +@Mapper(componentModel = ComponentModel.SPRING) +public interface BinderMapper +{ + @Mapping(target = "nbCards", expression = "java(binder.getCards().size())") + BinderDTO toDto(final DBBinder binder); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "cards", ignore = true) + @Mapping(target = "owner", ignore = true) + @Mapping(target = "version", ignore = true) + void updateFromDto(final BinderDTO dto, @MappingTarget final DBBinder binder); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/CardPrintMapper.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/CardPrintMapper.java @@ -0,0 +1,13 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.mappers; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants.ComponentModel; + +import fr.kevincorvisier.mtg.letstrade.controllers.dto.CardPrintDTO; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBCardPrint; + +@Mapper(componentModel = ComponentModel.SPRING) +public interface CardPrintMapper +{ + CardPrintDTO toDto(final DBCardPrint card); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/OracleCardMapper.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/OracleCardMapper.java @@ -0,0 +1,13 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.mappers; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants.ComponentModel; + +import fr.kevincorvisier.mtg.letstrade.controllers.dto.OracleCardDTO; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBOracleCard; + +@Mapper(componentModel = ComponentModel.SPRING) +public interface OracleCardMapper +{ + OracleCardDTO toDto(final DBOracleCard card); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/SetMapper.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/controllers/mappers/SetMapper.java @@ -0,0 +1,13 @@ +package fr.kevincorvisier.mtg.letstrade.controllers.mappers; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants.ComponentModel; + +import fr.kevincorvisier.mtg.letstrade.controllers.dto.SetDTO; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBSet; + +@Mapper(componentModel = ComponentModel.SPRING) +public interface SetMapper +{ + SetDTO toDto(final DBSet set); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/DatabaseConfiguration.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/DatabaseConfiguration.java @@ -5,6 +5,7 @@ import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.sqlite.SQLiteConfig; @Configuration public class DatabaseConfiguration @@ -12,9 +13,14 @@ public class DatabaseConfiguration @Bean public DataSource dataSource() { + final SQLiteConfig config = new SQLiteConfig(); + config.setExplicitReadOnly(true); + // config.setBusyTimeout(config.getBusyTimeout() * 2); + final DriverManagerDataSource dataSource = new DriverManagerDataSource(); dataSource.setDriverClassName("org.sqlite.JDBC"); dataSource.setUrl("jdbc:sqlite:your.db"); + dataSource.setConnectionProperties(config.toProperties()); return dataSource; } } diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/embeddable/BinderCardId.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/embeddable/BinderCardId.java @@ -0,0 +1,29 @@ +package fr.kevincorvisier.mtg.letstrade.database.embeddable; + +import java.io.Serializable; + +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@Embeddable + +@Data +@NoArgsConstructor(access = AccessLevel.PRIVATE) // For Hibernate +@RequiredArgsConstructor +public class BinderCardId implements Serializable +{ + private static final long serialVersionUID = 1L; + + @NonNull + @NotNull + private Long binderId; + + @NonNull + @NotNull + private Long cardId; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBBinder.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBBinder.java @@ -3,6 +3,7 @@ package fr.kevincorvisier.mtg.letstrade.database.entities; import java.util.Collection; import jakarta.persistence.Column; +import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -17,7 +18,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.NonNull; -//@Entity(name = "binder") +@Entity(name = "binder") @Data @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -27,7 +28,7 @@ public class DBBinder { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(updatable = false, nullable = false) + @Column(updatable = false, nullable = false, columnDefinition = "integer") private Long id; @Version @@ -38,6 +39,11 @@ public class DBBinder @ManyToOne(optional = false) private DBUser owner; + @NonNull + @NotNull + @Column(nullable = false) + private String name; + @OneToMany private Collection<DBBinderCard> cards; } diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBBinderCard.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBBinderCard.java @@ -1,13 +1,17 @@ package fr.kevincorvisier.mtg.letstrade.database.entities; -import jakarta.persistence.ManyToMany; +import fr.kevincorvisier.mtg.letstrade.database.embeddable.BinderCardId; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -//@Entity(name = "binder_card") +@Entity(name = "binder_card") /** * Represents one or several identical printed card(s) in a user binder @@ -18,11 +22,16 @@ import lombok.NoArgsConstructor; @Builder(toBuilder = true) public class DBBinderCard { - @ManyToMany + @EmbeddedId + private BinderCardId id; + + @ManyToOne + @MapsId("binderId") private DBBinder binder; - @ManyToMany - private DBPrintedCard card; + @ManyToOne + @MapsId("cardId") + private DBCardPrint card; - private int quantity; + private long quantity; } diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBCardPrint.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBCardPrint.java @@ -0,0 +1,78 @@ +package fr.kevincorvisier.mtg.letstrade.database.entities; + +import java.util.UUID; + +import fr.kevincorvisier.mtg.letstrade.database.CardFinish; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@Entity(name = "card") +@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "scryfallId", "finish" }) }) + +@Data +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(toBuilder = true) +public class DBCardPrint +{ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(updatable = false, nullable = false, columnDefinition = "integer") + private Long id; + + @Version + private short version; + + /** + * A unique ID for this card in Scryfall’s database. + */ + @NonNull + @NotNull + @Column(updatable = false, nullable = false) + private UUID scryfallId; + + @NonNull + @NotNull + @ManyToOne(optional = false) + private DBOracleCard oracleCard; + + @NonNull + @NotNull + @ManyToOne(optional = false) + private DBSet set; + + /** + * A language code for this printing. + */ + @NonNull + @NotNull + @Column(nullable = false) + private String lang; + + @NonNull + @NotNull + @Column(nullable = false) + private CardFinish finish; + + /** + * This card’s collector number. Note that collector numbers can contain non-numeric characters, such as letters or ★. + */ + @NonNull + @NotNull + @Column(nullable = false) + private String collectorNumber; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBPrintedCard.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/entities/DBPrintedCard.java @@ -1,78 +0,0 @@ -package fr.kevincorvisier.mtg.letstrade.database.entities; - -import java.util.UUID; - -import fr.kevincorvisier.mtg.letstrade.database.CardFinish; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import jakarta.persistence.Version; -import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.NonNull; - -@Entity(name = "card") -@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "scryfallId", "finish" }) }) - -@Data -@NoArgsConstructor(access = AccessLevel.PRIVATE) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder(toBuilder = true) -public class DBPrintedCard -{ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(updatable = false, nullable = false, columnDefinition = "integer") - private Long id; - - @Version - private short version; - - /** - * A unique ID for this card in Scryfall’s database. - */ - @NonNull - @NotNull - @Column(updatable = false, nullable = false) - private UUID scryfallId; - - @NonNull - @NotNull - @ManyToOne(optional = false) - private DBOracleCard oracleCard; - - @NonNull - @NotNull - @ManyToOne(optional = false) - private DBSet set; - - /** - * A language code for this printing. - */ - @NonNull - @NotNull - @Column(nullable = false) - private String lang; - - @NonNull - @NotNull - @Column(nullable = false) - private CardFinish finish; - - /** - * This card’s collector number. Note that collector numbers can contain non-numeric characters, such as letters or ★. - */ - @NonNull - @NotNull - @Column(nullable = false) - private String collectorNumber; -} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/exceptions/BinderNotFoundException.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/exceptions/BinderNotFoundException.java @@ -0,0 +1,15 @@ +package fr.kevincorvisier.mtg.letstrade.database.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class BinderNotFoundException extends Exception +{ + private static final long serialVersionUID = 1L; + + public BinderNotFoundException(final long id) + { + super("Binder not found: id=" + id); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/providers/DBBinderProvider.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/providers/DBBinderProvider.java @@ -0,0 +1,22 @@ +package fr.kevincorvisier.mtg.letstrade.database.providers; + +import org.springframework.stereotype.Service; + +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinder; +import fr.kevincorvisier.mtg.letstrade.database.exceptions.BinderNotFoundException; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBBinderRepository; +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class DBBinderProvider +{ + private final DBBinderRepository repository; + + public DBBinder findByIdOrThrow(@NonNull @NotNull final Long id) throws BinderNotFoundException + { + return repository.findById(id).orElseThrow(() -> new BinderNotFoundException(id)); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBBinderCardRepository.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBBinderCardRepository.java @@ -0,0 +1,15 @@ +package fr.kevincorvisier.mtg.letstrade.database.repositories; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.CrudRepository; + +import fr.kevincorvisier.mtg.letstrade.database.embeddable.BinderCardId; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinder; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinderCard; +import jakarta.validation.constraints.NotNull; + +public interface DBBinderCardRepository extends CrudRepository<DBBinderCard, BinderCardId> +{ + Page<DBBinderCard> findAllByBinder(@NotNull final DBBinder binder, @NotNull final Pageable pageable); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBBinderRepository.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBBinderRepository.java @@ -0,0 +1,20 @@ +package fr.kevincorvisier.mtg.letstrade.database.repositories; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.CrudRepository; + +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinder; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBUser; +import jakarta.validation.constraints.NotNull; + +public interface DBBinderRepository extends CrudRepository<DBBinder, Long> +{ + Page<DBBinder> findAllByOwnerLogin(@NotNull final String ownerLogin, @NotNull final Pageable pageable); + + Iterable<DBBinder> findByOwnerLoginAndName(@NotNull final String ownerLogin, @NotNull final String name); + + Optional<DBBinder> findByOwnerAndName(@NotNull final DBUser owner, @NotNull final String name); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBCardRepository.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBCardRepository.java @@ -1,15 +0,0 @@ -package fr.kevincorvisier.mtg.letstrade.database.repositories; - -import java.util.Optional; -import java.util.UUID; - -import org.springframework.data.repository.CrudRepository; - -import fr.kevincorvisier.mtg.letstrade.database.entities.DBPrintedCard; - -public interface DBCardRepository extends CrudRepository<DBPrintedCard, Long> -{ - Iterable<DBPrintedCard> findAllByScryfallIdIn(final Iterable<UUID> ids); - - Optional<DBPrintedCard> findByScryfallId(final UUID id); -} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBPrintedCardRepository.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/database/repositories/DBPrintedCardRepository.java @@ -0,0 +1,21 @@ +package fr.kevincorvisier.mtg.letstrade.database.repositories; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.repository.CrudRepository; + +import fr.kevincorvisier.mtg.letstrade.database.CardFinish; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBCardPrint; +import jakarta.validation.constraints.NotNull; + +public interface DBPrintedCardRepository extends CrudRepository<DBCardPrint, Long> +{ + Iterable<DBCardPrint> findAllByScryfallIdIn(final Iterable<UUID> ids); + + List<DBCardPrint> findAllByOracleCardNameAndSetCodeAndCollectorNumberAndLangAndFinish(@NotNull final String oracleCardName, @NotNull final String setCode, + @NotNull final String collectorNumber, @NotNull final String lang, @NotNull final CardFinish finish); + + Optional<DBCardPrint> findByScryfallId(final UUID id); +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionCsvReader.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionCsvReader.java @@ -0,0 +1,56 @@ +package fr.kevincorvisier.mtg.letstrade.manabox; + +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Map; + +import com.opencsv.CSVReaderHeaderAware; +import com.opencsv.exceptions.CsvValidationException; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; + +public class ManaBoxCollectionCsvReader implements Closeable +{ + private final CSVReaderHeaderAware csvReader; + + public ManaBoxCollectionCsvReader(final byte[] csv) throws IOException + { + csvReader = new CSVReaderHeaderAware(new InputStreamReader(new ByteArrayInputStream(csv))); + } + + @Override + public void close() throws IOException + { + csvReader.close(); + } + + @Nullable + public ManaBoxCollectionEntry readEntry() throws CsvValidationException, IOException, ManaBoxFieldMissingException + { + final Map<String, String> row = csvReader.readMap(); + if (row == null) + return null; // End of file + + return ManaBoxCollectionEntry.builder() // + .binderName(getFieldValue("Binder Name", row)) // + .name(getFieldValue("Name", row)) // + .setCode(getFieldValue("Set code", row)) // + .collectorNumber(getFieldValue("Collector number", row)) // + .foil(getFieldValue("Foil", row)) // + .quantity(Long.parseLong(getFieldValue("Quantity", row))) // + .language(getFieldValue("Language", row)) // + .build(); + } + + @NotNull + private static String getFieldValue(@NotNull final String fieldName, @NotNull final Map<String, String> row) throws ManaBoxFieldMissingException + { + final String value = row.get(fieldName); + if (value == null || value.isBlank()) + throw new ManaBoxFieldMissingException(fieldName, row); + return value; + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionEntry.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionEntry.java @@ -0,0 +1,39 @@ +package fr.kevincorvisier.mtg.letstrade.manabox; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Data +@Builder +public class ManaBoxCollectionEntry +{ + @NonNull + @NotNull + private final String binderName; + + @NonNull + @NotNull + private final String name; + + @NonNull + @NotNull + private final String setCode; + + @NonNull + @NotNull + private final String collectorNumber; + + @NonNull + @NotNull + private final String foil; + + @NonNull + @NotNull + private final Long quantity; + + @NonNull + @NotNull + private final String language; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionImporter.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxCollectionImporter.java @@ -0,0 +1,90 @@ +package fr.kevincorvisier.mtg.letstrade.manabox; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import fr.kevincorvisier.mtg.letstrade.database.CardFinish; +import fr.kevincorvisier.mtg.letstrade.database.embeddable.BinderCardId; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinder; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBBinderCard; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBCardPrint; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBUser; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBBinderCardRepository; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBBinderRepository; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBPrintedCardRepository; +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ManaBoxCollectionImporter +{ + private final DBBinderRepository binderRepository; + private final DBPrintedCardRepository cardRepository; + private final DBBinderCardRepository binderCardRepository; + + public void test(@NonNull @NotNull final DBUser user, final byte[] csv) throws IOException + { + final List<String> errors = new ArrayList<>(); + + try (final ManaBoxCollectionCsvReader csvReader = new ManaBoxCollectionCsvReader(csv)) + { + while (true) + { + try + { + final ManaBoxCollectionEntry entry = csvReader.readEntry(); + if (entry == null) + break; + + importLine(user, entry); + } + catch (final Exception e) + { + errors.add(e.getMessage()); + + log.warn("Error while importing", e); + } + } + } + + log.info("Errors:"); + for (final String error : errors) + log.info("- {}", error); + } + + private void importLine(@NotNull final DBUser owner, @NotNull final ManaBoxCollectionEntry entry) throws Exception + { + final List<DBCardPrint> potentialMatches = cardRepository.findAllByOracleCardNameAndSetCodeAndCollectorNumberAndLangAndFinish(entry.getName(), + entry.getSetCode().toLowerCase(), entry.getCollectorNumber(), entry.getLanguage(), + entry.getFoil().equals("foil") ? CardFinish.FOIL : CardFinish.NONFOIL); + + if (potentialMatches.isEmpty()) + throw new Exception("No card matching entry in database: " + entry); + else if (potentialMatches.size() > 1) + throw new Exception("More than 1 match for entry " + entry + " in our database: " + potentialMatches); + + final DBCardPrint card = potentialMatches.get(0); + + log.info("Found: {}", card); + + final DBBinder binder = binderRepository.findByOwnerAndName(owner, entry.getBinderName()) + .orElseGet(() -> binderRepository.save(DBBinder.builder().owner(owner).name(entry.getBinderName()).build())); + + final BinderCardId id = new BinderCardId(binder.getId(), card.getId()); + final DBBinderCard binderCard = binderCardRepository.findById(id) // + .orElseGet(() -> binderCardRepository.save(DBBinderCard.builder().id(id).binder(binder).card(card).build())); + + final DBBinderCard toSave = binderCard.toBuilder() // + .quantity(binderCard.getQuantity() + entry.getQuantity()) // + .build(); + + binderCardRepository.save(toSave); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxFieldMissingException.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/manabox/ManaBoxFieldMissingException.java @@ -0,0 +1,16 @@ +package fr.kevincorvisier.mtg.letstrade.manabox; + +import java.util.Map; + +import jakarta.validation.constraints.NotNull; +import lombok.NonNull; + +public class ManaBoxFieldMissingException extends Exception +{ + private static final long serialVersionUID = 1L; + + /* package */ ManaBoxFieldMissingException(@NonNull @NotNull final String fieldName, @NonNull @NotNull final Map<String, String> row) + { + super("Field '" + fieldName + "' missing or empty in row " + row); + } +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/ScryfallRestClient.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/ScryfallRestClient.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.core.JsonToken; import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallBulkData; import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallCard; import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallCard.ScryfallCardBuilder; +import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallCardFace; +import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallCardFace.ScryfallCardFaceBuilder; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,7 +40,6 @@ public class ScryfallRestClient final JsonFactory jfactory = new JsonFactory(); // final JsonParser jParser = jfactory.createParser(bulkData.getDownloadUri().toURL()); final JsonParser jParser = jfactory.createParser(new File("/home/kebi/tmp/all-cards-20250315092148.json")); - // final JsonParser jParser = jfactory.createParser(new File("/home/kebi/tmp/default-cards-20250313214259.json")); long nb = 0; Collection<ScryfallCard> batch = new HashSet<>(); @@ -95,6 +96,9 @@ public class ScryfallRestClient jParser.nextToken(); builder.lang(jParser.getText()); break; + case "card_faces": + builder.cardFaces(readArray(jParser, this::readCardFace)); + break; case "set": jParser.nextToken(); builder.set(jParser.getText()); @@ -112,13 +116,13 @@ public class ScryfallRestClient builder.collectorNumber(jParser.getText()); break; case "games": - builder.games(readArray(jParser)); + builder.games(readArray(jParser, JsonParser::getText)); break; case "finishes": - builder.finishes(readArray(jParser)); + builder.finishes(readArray(jParser, JsonParser::getText)); break; case "promo_types": - builder.promoTypes(readArray(jParser)); + builder.promoTypes(readArray(jParser, JsonParser::getText)); break; default: jParser.nextToken(); @@ -134,29 +138,61 @@ public class ScryfallRestClient log.error("Error while decoding {}", builder, e); throw e; } - } @NotNull - private Collection<String> readArray(@NotNull final JsonParser jParser) throws IOException + private <T> Collection<@NotNull T> readArray(@NotNull final JsonParser jParser, @NotNull final ObjectDecoder<T> objDecoder) throws IOException { - final Collection<String> values = new HashSet<>(); + final Collection<@NotNull T> values = new HashSet<>(); if (jParser.nextToken() == JsonToken.START_ARRAY) { while (jParser.nextToken() != JsonToken.END_ARRAY) { - values.add(jParser.getText()); + values.add(objDecoder.decode(jParser)); } } return values; } + @NotNull + private ScryfallCardFace readCardFace(@NotNull final JsonParser jParser) throws IOException + { + final ScryfallCardFaceBuilder builder = ScryfallCardFace.builder(); + + while (jParser.nextToken() != JsonToken.END_OBJECT) + { + switch (jParser.currentName()) + { + case "name": + jParser.nextToken(); + builder.name(jParser.getText()); + break; + case "oracle_id": + jParser.nextToken(); + builder.oracleId(UUID.fromString(jParser.getText())); + break; + default: + jParser.nextToken(); + jParser.skipChildren(); + break; + } + } + + return builder.build(); + } + public ScryfallBulkData getBulkDataByType(@NotNull final String type) { final Map<String, String> uriVariables = Map.of("type", type); return restTemplate.getForObject("/bulk-data/{type}", ScryfallBulkData.class, uriVariables); } + + interface ObjectDecoder<T> + { + @NotNull + T decode(final JsonParser jParser) throws IOException; + } } diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/domain/ScryfallCard.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/domain/ScryfallCard.java @@ -81,4 +81,10 @@ public class ScryfallCard */ @Nullable private Collection<String> promoTypes; + + /** + * An array of Card Face objects, if this card is multifaced. + */ + @Nullable + private Collection<ScryfallCardFace> cardFaces; } diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/domain/ScryfallCardFace.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/scryfall/domain/ScryfallCardFace.java @@ -0,0 +1,27 @@ +package fr.kevincorvisier.mtg.letstrade.scryfall.domain; + +import java.util.UUID; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Data +@Builder +public class ScryfallCardFace +{ + /** + * The name of this particular face. + */ + @NonNull + @NotNull + private String name; + + /** + * The Oracle ID of this particular face, if the card is reversible. + */ + @Nullable + private UUID oracleId; +} diff --git a/src/main/java/fr/kevincorvisier/mtg/letstrade/tasks/ScryfallSynchronizationTask.java b/src/main/java/fr/kevincorvisier/mtg/letstrade/tasks/ScryfallSynchronizationTask.java @@ -1,6 +1,7 @@ package fr.kevincorvisier.mtg.letstrade.tasks; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -8,25 +9,29 @@ import java.util.HashSet; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import fr.kevincorvisier.mtg.letstrade.database.CardFinish; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBCardPrint; +import fr.kevincorvisier.mtg.letstrade.database.entities.DBCardPrint.DBCardPrintBuilder; import fr.kevincorvisier.mtg.letstrade.database.entities.DBOracleCard; import fr.kevincorvisier.mtg.letstrade.database.entities.DBOracleCard.DBOracleCardBuilder; -import fr.kevincorvisier.mtg.letstrade.database.entities.DBPrintedCard; -import fr.kevincorvisier.mtg.letstrade.database.entities.DBPrintedCard.DBPrintedCardBuilder; import fr.kevincorvisier.mtg.letstrade.database.entities.DBSet; import fr.kevincorvisier.mtg.letstrade.database.entities.DBSet.DBSetBuilder; -import fr.kevincorvisier.mtg.letstrade.database.repositories.DBCardRepository; import fr.kevincorvisier.mtg.letstrade.database.repositories.DBOracleCardRepository; +import fr.kevincorvisier.mtg.letstrade.database.repositories.DBPrintedCardRepository; import fr.kevincorvisier.mtg.letstrade.database.repositories.DBSetRepository; import fr.kevincorvisier.mtg.letstrade.scryfall.ScryfallRestClient; import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallCard; +import fr.kevincorvisier.mtg.letstrade.scryfall.domain.ScryfallCardFace; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; import lombok.Data; +import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,7 +42,7 @@ public class ScryfallSynchronizationTask { private final DBOracleCardRepository oracleCardRepository; private final DBSetRepository setRepository; - private final DBCardRepository cardRepository; + private final DBPrintedCardRepository cardRepository; @Value("${tasks.scryfall-synchronization.enabled}") private final boolean enabled; @@ -46,6 +51,10 @@ public class ScryfallSynchronizationTask private final ScryfallRestClient scryfall; + private final Collection<Long> cardSaveAllTime = new ArrayList<>(); + private final Collection<Long> setSaveAllTime = new ArrayList<>(); + private final Collection<Long> oracleCardSaveAllTime = new ArrayList<>(); + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.DAYS) public void synchronize() { @@ -71,6 +80,34 @@ public class ScryfallSynchronizationTask scryfall.getCards(this::accept, this::saveCards, batchSize); log.info("Synchronization took {} ms", System.currentTimeMillis() - start); + + log.info("Average card saveAll time: {}", cardSaveAllTime.stream().collect(Collectors.summarizingLong(l -> l))); + log.info("Average set saveAll time: {}", setSaveAllTime.stream().collect(Collectors.summarizingLong(l -> l))); + log.info("Average oracleCard saveAll time: {}", oracleCardSaveAllTime.stream().collect(Collectors.summarizingLong(l -> l))); + } + + @Nullable + private static OracleCard getOracleCard(final ScryfallCard card) + { + final UUID oracleId = card.getOracleId(); + if (oracleId != null) + return new OracleCard(card.getName(), oracleId); + + final Collection<ScryfallCardFace> faces = card.getCardFaces(); + if (faces != null) + { + final Collection<OracleCard> oracleCards = new HashSet<>(); + for (final ScryfallCardFace face : faces) + { + final UUID faceOracleId = face.getOracleId(); + if (faceOracleId != null) + oracleCards.add(new OracleCard(face.getName(), faceOracleId)); + } + if (oracleCards.size() == 1) + return oracleCards.iterator().next(); + } + + return null; } private boolean accept(final ScryfallCard card) @@ -79,13 +116,8 @@ public class ScryfallSynchronizationTask if (!card.getGames().contains("paper")) return false; - // Ignore cards printed on thick cardstock - final Collection<String> promoTypes = card.getPromoTypes(); - if (promoTypes != null && promoTypes.contains("thick")) - return false; - - final UUID oracleId = card.getOracleId(); - if (oracleId == null) + final OracleCard oracleCard = getOracleCard(card); + if (oracleCard == null) return false; return true; @@ -103,7 +135,11 @@ public class ScryfallSynchronizationTask { final Collection<UUID> scryfallIds = new HashSet<>(); for (final ScryfallCard card : cards) - scryfallIds.add(card.getOracleId()); + { + final OracleCard oracleCard = getOracleCard(card); + if (oracleCard != null) + scryfallIds.add(oracleCard.getOracleId()); + } final Map<UUID, DBOracleCard> existingByScryfallId = new HashMap<>(); for (final DBOracleCard oracleCard : oracleCardRepository.findAllByScryfallIdIn(scryfallIds)) @@ -113,21 +149,25 @@ public class ScryfallSynchronizationTask for (final ScryfallCard card : cards) { - final UUID oracleId = card.getOracleId(); - if (oracleId == null) + final OracleCard oracleCard = getOracleCard(card); + if (oracleCard == null) continue; - final DBOracleCard existing = existingByScryfallId.get(oracleId); - final DBOracleCardBuilder builder = existing != null ? existing.toBuilder() : DBOracleCard.builder().scryfallId(oracleId); + final DBOracleCard existing = existingByScryfallId.get(oracleCard.getOracleId()); + final DBOracleCardBuilder builder = existing != null ? existing.toBuilder() : DBOracleCard.builder().scryfallId(oracleCard.getOracleId()); - builder.name(card.getName()); + builder.name(oracleCard.getName()); final DBOracleCard updated = builder.build(); if (existing == null || !existing.equals(updated)) toSave.add(updated); } - for (final DBOracleCard oracleCard : oracleCardRepository.saveAll(toSave)) + final long start = System.nanoTime(); + final Iterable<DBOracleCard> saved = oracleCardRepository.saveAll(toSave); + oracleCardSaveAllTime.add(System.nanoTime() - start); + + for (final DBOracleCard oracleCard : saved) existingByScryfallId.put(oracleCard.getScryfallId(), oracleCard); return Collections.unmodifiableMap(existingByScryfallId); @@ -159,7 +199,11 @@ public class ScryfallSynchronizationTask toSave.add(updated); } - for (final DBSet set : setRepository.saveAll(toSave)) + final long start = System.nanoTime(); + final Iterable<DBSet> saved = setRepository.saveAll(toSave); + setSaveAllTime.add(System.nanoTime() - start); + + for (final DBSet set : saved) existingByScryfallId.put(set.getScryfallId(), set); return Collections.unmodifiableMap(existingByScryfallId); @@ -171,22 +215,22 @@ public class ScryfallSynchronizationTask for (final ScryfallCard card : cards) scryfallIds.add(card.getId()); - final Map<DBPrintedCardMergeKey, DBPrintedCard> existingByScryfallId = new HashMap<>(); - for (final DBPrintedCard card : cardRepository.findAllByScryfallIdIn(scryfallIds)) + final Map<DBPrintedCardMergeKey, DBCardPrint> existingByScryfallId = new HashMap<>(); + for (final DBCardPrint card : cardRepository.findAllByScryfallIdIn(scryfallIds)) existingByScryfallId.put(new DBPrintedCardMergeKey(card), card); - final Collection<DBPrintedCard> toSave = new HashSet<>(); + final Collection<DBCardPrint> toSave = new HashSet<>(); for (final ScryfallCard card : cards) { - final UUID oracleId = card.getOracleId(); - if (oracleId == null) + final OracleCard oracleCard1 = getOracleCard(card); + if (oracleCard1 == null) { log.warn("Card without oracle_id: {}", card.getId()); continue; } - final DBOracleCard oracleCard = oracleCards.get(oracleId); + final DBOracleCard oracleCard = oracleCards.get(oracleCard1.getOracleId()); if (oracleCard == null) { log.error("Unable to find oracle card for card: {}", card.getId()); @@ -204,21 +248,23 @@ public class ScryfallSynchronizationTask { final CardFinish finish2 = CardFinish.valueOf(finish.toUpperCase()); - final DBPrintedCard existing = existingByScryfallId.get(new DBPrintedCardMergeKey(card, finish2)); - final DBPrintedCardBuilder builder = existing != null ? existing.toBuilder() : DBPrintedCard.builder().scryfallId(card.getId()).finish(finish2); + final DBCardPrint existing = existingByScryfallId.get(new DBPrintedCardMergeKey(card, finish2)); + final DBCardPrintBuilder builder = existing != null ? existing.toBuilder() : DBCardPrint.builder().scryfallId(card.getId()).finish(finish2); builder.oracleCard(oracleCard); builder.lang(card.getLang()); builder.set(set); builder.collectorNumber(card.getCollectorNumber()); - final DBPrintedCard updated = builder.build(); + final DBCardPrint updated = builder.build(); if (existing == null || !existing.equals(updated)) toSave.add(updated); } } + final long start = System.nanoTime(); cardRepository.saveAll(toSave); + cardSaveAllTime.add(System.nanoTime() - start); } @Data @@ -233,10 +279,22 @@ public class ScryfallSynchronizationTask this.finish = finish; } - public DBPrintedCardMergeKey(final DBPrintedCard card) + public DBPrintedCardMergeKey(final DBCardPrint card) { this.scryfallId = card.getScryfallId(); this.finish = card.getFinish(); } } + + @Data + private static class OracleCard + { + @NonNull + @NotNull + private final String name; + + @NonNull + @NotNull + private UUID oracleId; + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties @@ -4,7 +4,7 @@ spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect spring.jpa.hibernate.ddl-auto=validate # spring.jpa.hibernate.ddl-auto=create # spring.flyway.enabled=false -spring.jpa.show-sql=true +# spring.jpa.show-sql=true spring.jpa.properties.hibernate.jdbc.batch_size=10000 spring.jpa.properties.hibernate.order_inserts=true diff --git a/src/main/resources/db/migration/V0_0_1.sql b/src/main/resources/db/migration/V0_0_1.sql @@ -1,3 +1,21 @@ +CREATE TABLE binder ( + version smallint not null, + id integer, + owner_id integer not null, + name varchar(255) not null, + primary key (id) +); +CREATE TABLE binder_card ( + binder_id integer not null, + card_id integer not null, + quantity bigint not null, + primary key (binder_id, card_id) +); +CREATE TABLE binder_cards ( + binder_id integer not null, + cards_binder_id integer not null, + cards_card_id integer not null +); CREATE TABLE card ( finish tinyint not null check ( finish between 0 and 2 diff --git a/webpack.common.js b/webpack.common.js @@ -15,8 +15,19 @@ module.exports = { module: { rules: [ { - test: /\.css$/i, - use: [MiniCssExtractPlugin.loader, "css-loader"] + test: /\.scss$/i, + use: [ + MiniCssExtractPlugin.loader, + "css-loader", + { + loader: "sass-loader", + options: { + sassOptions: { + quietDeps: true + } + } + } + ] }, { test: /\.(ts|tsx)$/,