mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 17:01:16 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3b4762b76 | |||
| 2eb7c59f78 | |||
| 7cf25d671f | |||
| ab0e3c5ae2 | |||
| 9917e364c1 | |||
| c9e6bc2b6b | |||
| e5c830e945 | |||
| 42926f2fb5 | |||
| 8a50e8daba | |||
| 98f2575b5a | |||
| d6c7f60aee | |||
| 7935f2b18e | |||
| 5805101d24 | |||
| 8b7d45fdac | |||
| c1d42bdadd |
@@ -38,6 +38,8 @@ GitHub Actions workflows for CI, release automation, and policy checks.
|
||||
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
|
||||
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
|
||||
- Sonar coverage steps that use `gcovr` must exclude third-party build trees at discovery time with `--exclude-directories` (for example `build/_deps`) so gcov does not process dependency `.gcda` files.
|
||||
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`.
|
||||
- The Sonar scan also sets `-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp` so intentionally parallel wx per-game UI scaffolding does not dominate the duplication quality gate.
|
||||
- For Linux Sonar coverage jobs, keep compiler and gcov toolchain aligned; because `cmake/Toolchain.cmake` prefers Clang by default, set `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++` explicitly in the coverage configure step when using gcovr default `gcov`.
|
||||
- Keep `permissions` least-privilege:
|
||||
- reusable build workflows: `contents: read`
|
||||
|
||||
@@ -55,9 +55,10 @@ jobs:
|
||||
--sonarqube build/sonarqube-coverage.xml
|
||||
--exclude "build/_deps/"
|
||||
--exclude-directories "build/_deps"
|
||||
--exclude "^tests/"
|
||||
|
||||
- name: SonarQube Cloud scan
|
||||
uses: SonarSource/sonarqube-scan-action@v5
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
@@ -69,6 +70,8 @@ jobs:
|
||||
-Dsonar.sources=core,ui_wx,app
|
||||
-Dsonar.cfamily.compile-commands=build/compile_commands.json
|
||||
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
|
||||
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
|
||||
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
|
||||
|
||||
linux:
|
||||
name: Linux build + tests
|
||||
|
||||
@@ -55,9 +55,10 @@ jobs:
|
||||
--sonarqube build/sonarqube-coverage.xml
|
||||
--exclude "build/_deps/"
|
||||
--exclude-directories "build/_deps"
|
||||
--exclude "^tests/"
|
||||
|
||||
- name: SonarQube Cloud scan
|
||||
uses: SonarSource/sonarqube-scan-action@v5
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
@@ -69,6 +70,8 @@ jobs:
|
||||
-Dsonar.sources=core,ui_wx,app
|
||||
-Dsonar.cfamily.compile-commands=build/compile_commands.json
|
||||
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
|
||||
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
|
||||
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
|
||||
|
||||
compute-version:
|
||||
name: Determine semantic version
|
||||
|
||||
@@ -40,3 +40,6 @@ config.json
|
||||
configure.log
|
||||
build.log
|
||||
test.log
|
||||
|
||||
# Offline ETL caches (large third-party extracts)
|
||||
tools/pokemon_jp/_tcgdex_cards_database/
|
||||
|
||||
@@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin
|
||||
## Project structure
|
||||
|
||||
- `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `<exeDir>/assets/` when linking `ccm`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` (offline preview fallbacks); `app/CMakeLists.txt` copies them to `<exeDir>/assets/` when linking `ccm`.
|
||||
- `app/` — `ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
|
||||
- `tests/` — `ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
|
||||
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
|
||||
@@ -52,9 +52,17 @@ Run from the **workspace root**.
|
||||
- Run the app:
|
||||
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
|
||||
- Run tests (CCM_BUILD_TESTS defaults to ON):
|
||||
`ctest --test-dir build --output-on-failure` — current baseline: **180 tests, all green**.
|
||||
`ctest --test-dir build --output-on-failure` — current baseline: **226 tests, all green**.
|
||||
- Build tests only:
|
||||
`cmake --build build --target ccm_core_tests`
|
||||
- Local coverage env setup (one-time, Windows/MSYS2):
|
||||
`python -m venv .venv_cov`
|
||||
`& "P:/msys2/msys64/usr/bin/pacman.exe" -S --noconfirm mingw-w64-ucrt-x86_64-python-lxml mingw-w64-ucrt-x86_64-python-gcovr`
|
||||
- Coverage check (core-focused):
|
||||
`cmake -S . -B build-cov -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCCM_BUILD_TESTS=ON -DCMAKE_C_FLAGS=--coverage -DCMAKE_CXX_FLAGS=--coverage -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`
|
||||
`cmake --build build-cov --target ccm_core_tests --parallel`
|
||||
`ctest --test-dir build-cov --output-on-failure`
|
||||
`& "P:/msys2/msys64/ucrt64/bin/gcovr.exe" -r . --object-directory build-cov --filter "core/" --exclude "build/_deps/" --exclude "build-cov/_deps/" --exclude-directories "build/_deps" --exclude-directories "build-cov/_deps" --print-summary`
|
||||
|
||||
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
|
||||
>
|
||||
@@ -76,7 +84,7 @@ Run from the **workspace root**.
|
||||
- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically.
|
||||
- `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically.
|
||||
- `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.cache/preview-cache/` — **next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path.
|
||||
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
|
||||
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
|
||||
|
||||
## Windows UI theming guardrails
|
||||
|
||||
@@ -84,7 +92,7 @@ Run from the **workspace root**.
|
||||
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
|
||||
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
|
||||
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
|
||||
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
|
||||
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme` in `Theme.cpp` (opt out of immersive dark mode + parent `WM_CTLCOLOREDIT` subclass — that message goes to the EDIT's parent, not `MainFrame`).
|
||||
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
|
||||
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
|
||||
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
|
||||
@@ -96,11 +104,19 @@ Run from the **workspace root**.
|
||||
- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests.
|
||||
- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob.
|
||||
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
|
||||
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files.
|
||||
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
|
||||
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
|
||||
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
|
||||
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
|
||||
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
|
||||
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
|
||||
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
|
||||
|
||||
## Agent collaboration (Cursor / AI)
|
||||
|
||||
- **Never** `git commit` or `git push` unless the user **explicitly** asked you to commit and/or push (e.g. “commit this”, “push to origin”). Preparing diffs and suggesting commands is fine; performing those Git writes without explicit instruction is not.
|
||||
- **Never** check out another branch **to change it** unless the user **explicitly** asked you to work on that branch. Temporarily checking out another branch **read-only** (inspect history, compare files, run `git show`) is fine without asking; switch back to the working branch before making edits unless instructed otherwise.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Card Collection Manager 3
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
[](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
|
||||
|
||||
@@ -10,21 +9,46 @@ Currently, the application supports the following TCGs:
|
||||
- Magic the Gathering
|
||||
- Pokemon TCG
|
||||
- Yu-Gi-Oh!
|
||||
- Yu-Gi-Oh! (Bandai)
|
||||
- Digimon (Digi-Battle)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Magic The Gathering
|
||||
<details open>
|
||||
<summary>Magic The Gathering</summary>
|
||||
|
||||

|
||||
|
||||
### Pokemon TCG
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Pokemon TCG</summary>
|
||||
|
||||

|
||||
|
||||
### Yu-Gi-Oh!
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Yu-Gi-Oh!</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Yu-Gi-Oh! (Bandai)</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Digimon (Digi-Battle)</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Migrating From CCM1 And CCM2
|
||||
|
||||
|
||||
+5
-4
@@ -5,14 +5,14 @@ The `ccm` executable — composition root only. The single place where concrete
|
||||
## File pointers
|
||||
|
||||
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
|
||||
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` there so Yu-Gi-Oh! preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
|
||||
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png`, `ui_wx/assets/digibattle99_card_back.png`, and `ui_wx/assets/pokemon_jp_en_catalog.json` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks and Japanese Pokémon EN names work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonCollectionRepository<YuGiOhBandaiCard>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `YuGiOhBandaiSetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `YuGiOhBandaiGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, `YuGiOhBandaiGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
|
||||
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
|
||||
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
|
||||
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`).
|
||||
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
|
||||
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
|
||||
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
|
||||
@@ -21,7 +21,8 @@ The `ccm` executable — composition root only. The single place where concrete
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- The **`POST_BUILD` copy of `ygo_card_back.png`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
|
||||
- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_card_back.png` / `pokemon_jp_en_catalog.json`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
|
||||
- On MinGW-w64 Windows, POST_BUILD also copies `libstdc++-6.dll` / `libgcc_s_seh-1.dll` / `libwinpthread-1.dll` from the compiler directory into `$<TARGET_FILE_DIR:ccm>` so the exe does not load a mismatched runtime from `PATH`.
|
||||
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
|
||||
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
|
||||
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
|
||||
|
||||
+30
-2
@@ -19,9 +19,37 @@ target_link_libraries(ccm
|
||||
ccm_warnings
|
||||
)
|
||||
|
||||
# Yu-Gi-Oh! preview fallback image (used when network card-back URLs fail).
|
||||
# Yu-Gi-Oh! / Digi-Battle preview fallback images and the Japanese Pokémon
|
||||
# EN name catalog (used when network card-back URLs fail or no public URL
|
||||
# exists / for JP English display names).
|
||||
add_custom_command(TARGET ccm POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_classic"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png")
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/digibattle99_card_back.png"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_en_catalog.json"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_en_catalog.json"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_classic"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_classic")
|
||||
|
||||
# MinGW-w64: ship the toolchain runtime next to ccm3.exe so Explorer / IDE
|
||||
# launches do not pick a mismatched libstdc++ off PATH (symptoms: Entry Point
|
||||
# Not Found for __emutls_v._ZSt11__once_call in libcpr.dll).
|
||||
if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
get_filename_component(_ccm_mingw_bin "${CMAKE_CXX_COMPILER}" DIRECTORY)
|
||||
foreach(_ccm_rt_dll IN ITEMS libstdc++-6.dll libgcc_s_seh-1.dll libwinpthread-1.dll)
|
||||
if(EXISTS "${_ccm_mingw_bin}/${_ccm_rt_dll}")
|
||||
add_custom_command(TARGET ccm POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${_ccm_mingw_bin}/${_ccm_rt_dll}"
|
||||
"$<TARGET_FILE_DIR:ccm>/${_ccm_rt_dll}"
|
||||
VERBATIM)
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
+91
-7
@@ -2,12 +2,18 @@
|
||||
// it to the wxWidgets UI layer. This is the only place where concrete adapter
|
||||
// types are mentioned - everything downstream depends on interfaces.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
#include "ccm/infra/JsonCollectionRepository.hpp"
|
||||
#include "ccm/infra/JsonSetRepository.hpp"
|
||||
@@ -17,12 +23,18 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/services/PokemonSetCatalogService.hpp"
|
||||
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/AppContext.hpp"
|
||||
#include "ccm/ui/DigiBattle99GameView.hpp"
|
||||
#include "ccm/ui/MagicGameView.hpp"
|
||||
#include "ccm/ui/MainFrame.hpp"
|
||||
#include "ccm/ui/PokemonGameView.hpp"
|
||||
#include "ccm/ui/YuGiOhBandaiGameView.hpp"
|
||||
#include "ccm/ui/YuGiOhGameView.hpp"
|
||||
|
||||
#include <wx/app.h>
|
||||
@@ -42,9 +54,12 @@ namespace {
|
||||
// need for the repositories to know about concrete game module classes.
|
||||
std::string dirNameForGame(ccm::Game g) {
|
||||
switch (g) {
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
case ccm::Game::YuGiOh: return "yugioh";
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
case ccm::Game::YuGiOh: return "yugioh";
|
||||
case ccm::Game::DigiBattle99: return "digibattle99";
|
||||
case ccm::Game::YuGiOhBandai: return "yugiohbandai";
|
||||
case ccm::Game::JapanesePokemon: return "pokemon";
|
||||
}
|
||||
return "magic";
|
||||
}
|
||||
@@ -80,6 +95,19 @@ public:
|
||||
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
|
||||
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
|
||||
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
|
||||
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
|
||||
ygoBandaiMod_ = std::make_unique<ccm::YuGiOhBandaiGameModule>(*http_);
|
||||
|
||||
ccm::JapanesePokemonEnCatalog jpCatalog;
|
||||
{
|
||||
const auto catalogPath = exeDir / "assets" / "pokemon_jp_en_catalog.json";
|
||||
if (auto text = fs_->readText(catalogPath); text) {
|
||||
if (auto parsed = ccm::JapanesePokemonEnCatalog::parse(text.value()); parsed) {
|
||||
jpCatalog = std::move(parsed).value();
|
||||
}
|
||||
}
|
||||
}
|
||||
jpPokeMod_ = std::make_unique<ccm::JapanesePokemonGameModule>(*http_, std::move(jpCatalog));
|
||||
|
||||
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
@@ -87,7 +115,22 @@ public:
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
ygoRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
digiBattle99Repo_ =
|
||||
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
ygoBandaiRepo_ =
|
||||
std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
|
||||
digiBattle99CatalogStore_ =
|
||||
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
ygoCatalogStore_ =
|
||||
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
ygoMod_->setCatalogService(ygoCatalogStore_.get());
|
||||
ygoBandaiCatalogStore_ =
|
||||
std::make_unique<ccm::YuGiOhBandaiSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
pokeCatalogStore_ =
|
||||
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
|
||||
|
||||
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
|
||||
@@ -97,10 +140,19 @@ public:
|
||||
*pokeRepo_, *imgStore_);
|
||||
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
|
||||
*ygoRepo_, *imgStore_);
|
||||
digiBattle99CollSvc_ =
|
||||
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
|
||||
*digiBattle99Repo_, *imgStore_);
|
||||
ygoBandaiCollSvc_ =
|
||||
std::make_unique<ccm::CollectionService<ccm::YuGiOhBandaiCard>>(
|
||||
*ygoBandaiRepo_, *imgStore_);
|
||||
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
|
||||
setSvc_->registerModule(magicMod_.get());
|
||||
setSvc_->registerModule(pokeMod_.get());
|
||||
setSvc_->registerModule(ygoMod_.get());
|
||||
setSvc_->registerModule(digiBattle99Mod_.get());
|
||||
setSvc_->registerModule(ygoBandaiMod_.get());
|
||||
setSvc_->registerModule(jpPokeMod_.get());
|
||||
|
||||
// Disk-backed preview cache lives next to the executable, in the same
|
||||
// location scope as config.json - NOT inside the user's data-storage
|
||||
@@ -116,18 +168,33 @@ public:
|
||||
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
|
||||
*fs_,
|
||||
exeDir / ".cache" / "preview-cache");
|
||||
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_, previewCache_.get());
|
||||
previewSvc_ = std::make_unique<ccm::CardPreviewService>(
|
||||
*http_,
|
||||
previewCache_.get(),
|
||||
fs_.get(),
|
||||
exeDir / "assets");
|
||||
previewSvc_->registerModule(*magicMod_);
|
||||
previewSvc_->registerModule(*pokeMod_);
|
||||
previewSvc_->registerModule(*ygoMod_);
|
||||
previewSvc_->registerModule(*digiBattle99Mod_);
|
||||
previewSvc_->registerModule(*ygoBandaiMod_);
|
||||
previewSvc_->registerModule(*jpPokeMod_);
|
||||
|
||||
// Per-game UI bundles. Order here is the order shown in the Game menu.
|
||||
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
|
||||
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
|
||||
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_,
|
||||
*pokeCatalogStore_);
|
||||
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
|
||||
*ygoCatalogStore_);
|
||||
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
|
||||
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*digiBattle99Mod_, *digiBattle99CatalogStore_);
|
||||
ygoBandaiView_ = std::make_unique<ccm::ui::YuGiOhBandaiGameView>(
|
||||
*config_, *ygoBandaiCollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*ygoBandaiMod_, *ygoBandaiCatalogStore_);
|
||||
|
||||
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
|
||||
*config_,
|
||||
@@ -137,7 +204,11 @@ public:
|
||||
*magicMod_,
|
||||
*pokeMod_,
|
||||
*ygoMod_,
|
||||
{ magicView_.get(), pokeView_.get(), ygoView_.get() },
|
||||
*digiBattle99Mod_,
|
||||
*ygoBandaiMod_,
|
||||
*jpPokeMod_,
|
||||
{ magicView_.get(), pokeView_.get(), ygoView_.get(), ygoBandaiView_.get(),
|
||||
digiBattle99View_.get() },
|
||||
});
|
||||
|
||||
auto* frame = new ccm::ui::MainFrame(*ctx_);
|
||||
@@ -158,21 +229,34 @@ private:
|
||||
std::unique_ptr<ccm::MagicGameModule> magicMod_;
|
||||
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
|
||||
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
|
||||
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
|
||||
std::unique_ptr<ccm::YuGiOhBandaiGameModule> ygoBandaiMod_;
|
||||
std::unique_ptr<ccm::JapanesePokemonGameModule> jpPokeMod_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>> ygoBandaiRepo_;
|
||||
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
|
||||
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
|
||||
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
|
||||
std::unique_ptr<ccm::YuGiOhBandaiSetCatalogService> ygoBandaiCatalogStore_;
|
||||
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
|
||||
std::unique_ptr<ccm::LocalImageStore> imgStore_;
|
||||
std::unique_ptr<ccm::ImageService> imgSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhBandaiCard>> ygoBandaiCollSvc_;
|
||||
std::unique_ptr<ccm::SetService> setSvc_;
|
||||
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
|
||||
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
|
||||
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
|
||||
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
|
||||
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
|
||||
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
|
||||
std::unique_ptr<ccm::ui::YuGiOhBandaiGameView> ygoBandaiView_;
|
||||
std::unique_ptr<ccm::ui::AppContext> ctx_;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ FetchContent_MakeAvailable(nlohmann_json)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need
|
||||
# a system libcurl. Used for Scryfall + pokemontcg.io REST calls.
|
||||
# a system libcurl. Used for Scryfall + TCGdex REST calls.
|
||||
#
|
||||
# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)`
|
||||
# rule that references `libcurl_shared`, which isn't in any export set when
|
||||
|
||||
+5
-5
@@ -4,12 +4,12 @@
|
||||
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `YuGiOhBandaiCard`, `YuGiOhBandaiSetCatalog` (Bandai pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`).
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `YuGiOhBandaiSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `yugiohbandai/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all expose a fully working set source + card preview source. `YuGiOhSetSource`, `YuGiOhBandaiSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
|
||||
- `src/` mirrors `include/ccm/` for non-template implementations.
|
||||
|
||||
## Conventions
|
||||
@@ -27,7 +27,7 @@
|
||||
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
|
||||
9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface.
|
||||
10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `<exeDir>/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `<hash>.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
|
||||
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
|
||||
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
|
||||
|
||||
## Adding a new game
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ add_library(ccm_core STATIC
|
||||
src/domain/MagicCard.cpp
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/YuGiOhCard.cpp
|
||||
src/domain/YuGiOhBandaiCard.cpp
|
||||
src/domain/YuGiOhBandaiSetCatalog.cpp
|
||||
src/domain/DigiBattle99Card.cpp
|
||||
src/domain/DigiBattle99SetCatalog.cpp
|
||||
src/domain/YuGiOhSetCatalog.cpp
|
||||
src/domain/PokemonSetCatalog.cpp
|
||||
src/domain/JapanesePokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
src/services/ConfigService.cpp
|
||||
@@ -15,6 +22,14 @@ add_library(ccm_core STATIC
|
||||
src/services/CardPreviewService.cpp
|
||||
src/services/CardSorter.cpp
|
||||
src/services/CardFilter.cpp
|
||||
src/services/DigiBattle99SetCompletion.cpp
|
||||
src/services/DigiBattle99SetCatalogService.cpp
|
||||
src/services/YuGiOhSetCompletion.cpp
|
||||
src/services/YuGiOhSetCatalogService.cpp
|
||||
src/services/YuGiOhBandaiSetCompletion.cpp
|
||||
src/services/YuGiOhBandaiSetCatalogService.cpp
|
||||
src/services/PokemonSetCompletion.cpp
|
||||
src/services/PokemonSetCatalogService.cpp
|
||||
|
||||
src/infra/CprHttpClient.cpp
|
||||
src/infra/StdFileSystem.cpp
|
||||
@@ -25,14 +40,27 @@ add_library(ccm_core STATIC
|
||||
src/games/magic/MagicSetSource.cpp
|
||||
src/games/magic/MagicCardPreviewSource.cpp
|
||||
src/games/magic/MagicGameModule.cpp
|
||||
src/games/pokemon/PokemonWestSetId.cpp
|
||||
src/games/pokemon/PokemonCollectionSetSync.cpp
|
||||
src/games/pokemon/PokemonSetSource.cpp
|
||||
src/games/pokemon/PokemonCardPreviewSource.cpp
|
||||
src/games/pokemon/PokemonGameModule.cpp
|
||||
src/games/yugioh/YuGiOhSetSource.cpp
|
||||
src/games/yugioh/YuGiOhCardPreviewSource.cpp
|
||||
src/games/yugioh/YuGiOhGameModule.cpp
|
||||
src/games/digibattle99/DigiBattle99SetSource.cpp
|
||||
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
|
||||
src/games/digibattle99/DigiBattle99GameModule.cpp
|
||||
src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp
|
||||
src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp
|
||||
src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp
|
||||
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
|
||||
src/games/pokemonjp/JapanesePokemonSetSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonGameModule.cpp
|
||||
|
||||
src/util/FsNames.cpp
|
||||
src/util/SetNoNatural.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99Card - Digimon Digi-Battle (1999 English) card model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99Card {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const DigiBattle99Card&, const DigiBattle99Card&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
|
||||
// completion. Filled from digimoncard.io bulk search.php (same payload as the
|
||||
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99CatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const DigiBattle99CatalogCard&,
|
||||
const DigiBattle99CatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalogPack&,
|
||||
const DigiBattle99SetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalog {
|
||||
std::vector<DigiBattle99SetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalog&,
|
||||
const DigiBattle99SetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -19,6 +20,14 @@ enum class Game {
|
||||
Magic,
|
||||
Pokemon,
|
||||
YuGiOh,
|
||||
DigiBattle99,
|
||||
YuGiOhBandai,
|
||||
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
|
||||
};
|
||||
|
||||
enum class PokemonRegion {
|
||||
West,
|
||||
Asia,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
@@ -27,8 +36,10 @@ enum class Language {
|
||||
French,
|
||||
Spanish,
|
||||
Italian,
|
||||
Chinese,
|
||||
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
|
||||
TraditionalChinese, // JSON / display: "T-Chinese"
|
||||
Japanese,
|
||||
Korean,
|
||||
Russian,
|
||||
};
|
||||
|
||||
@@ -48,24 +59,34 @@ enum class Theme {
|
||||
};
|
||||
|
||||
std::string_view to_string(Game g) noexcept;
|
||||
std::string_view to_string(PokemonRegion r) noexcept;
|
||||
std::string_view to_string(Language l) noexcept;
|
||||
std::string_view to_string(Condition c) noexcept;
|
||||
std::string_view to_string(Theme t) noexcept;
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
const std::array<Game, 3>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
|
||||
const std::array<Game, 5>& allGames() noexcept;
|
||||
const std::array<Language, 10>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
[[nodiscard]] std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Game pokemonBackendGame(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept;
|
||||
|
||||
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
|
||||
void to_json(nlohmann::json& j, Game v);
|
||||
void from_json(const nlohmann::json& j, Game& v);
|
||||
|
||||
void to_json(nlohmann::json& j, PokemonRegion v);
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Language v);
|
||||
void from_json(const nlohmann::json& j, Language& v);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCard - Japanese Pokémon TCG collection model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::Japanese};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c);
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases),
|
||||
// plus `region` (West/Asia) for unified West+Asia collections.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
@@ -28,6 +29,7 @@ struct PokemonCard {
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
|
||||
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
|
||||
// completion. West and Asia each persist their own file under
|
||||
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const PokemonCatalogCard&,
|
||||
const PokemonCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct PokemonSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<PokemonCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const PokemonSetCatalogPack&,
|
||||
const PokemonSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct PokemonSetCatalog {
|
||||
std::vector<PokemonSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const PokemonSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const PokemonSetCatalog&,
|
||||
const PokemonSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiCard - Bandai Carddass (pre-Konami) card model.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhBandaiCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::Japanese};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiCard&, const YuGiOhBandaiCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiSetCatalog: offline pack → card checklist for Bandai set
|
||||
// completion. Filled from Yugipedia set-gallery wikitext and persisted at
|
||||
// `<dataStorage>/yugiohbandai/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhBandaiCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
std::string rarity;
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiCatalogCard&,
|
||||
const YuGiOhBandaiCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhBandaiSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<YuGiOhBandaiCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiSetCatalogPack&,
|
||||
const YuGiOhBandaiSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhBandaiSetCatalog {
|
||||
std::vector<YuGiOhBandaiSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const YuGiOhBandaiSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const YuGiOhBandaiSetCatalog&,
|
||||
const YuGiOhBandaiSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -20,7 +20,6 @@ struct YuGiOhCard {
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
std::string rarityCode;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
|
||||
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
|
||||
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
/// YGOPRODeck `set_rarity` for this printing when known (optional;
|
||||
/// older `set-catalog.json` files omit it).
|
||||
std::string rarity{};
|
||||
|
||||
friend bool operator==(const YuGiOhCatalogCard&,
|
||||
const YuGiOhCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<YuGiOhCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const YuGiOhSetCatalogPack&,
|
||||
const YuGiOhSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhSetCatalog {
|
||||
std::vector<YuGiOhSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const YuGiOhSetCatalog&,
|
||||
const YuGiOhSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -23,6 +23,10 @@ public:
|
||||
// Implementations return a vector that has already been filtered
|
||||
// (e.g. no digital-only sets) and sorted by release date ascending.
|
||||
virtual Result<std::vector<Set>> fetchAll() = 0;
|
||||
|
||||
// Optional post-process for locally cached set lists (e.g. inject products
|
||||
// the upstream API omits). Default is a no-op. Called by SetService::getSets.
|
||||
virtual void augmentCachedSets(std::vector<Set>& /*sets*/) const {}
|
||||
};
|
||||
|
||||
class IGameModule {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99CardPreviewSource: digimoncard.io search + CDN card images for
|
||||
// Digimon Digi-Battle (1999 English).
|
||||
//
|
||||
// Preview key middle slot is Set.name (pack display name) so search.php?pack=
|
||||
// works without a reverse slug map. When setNo is present, the CDN URL is
|
||||
// built directly — no search round-trip.
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99CardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
static constexpr const char* kImageBase =
|
||||
"https://images.digimoncard.io/images/cards/";
|
||||
|
||||
explicit DigiBattle99CardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
|
||||
// Does not invent zero-padding — CDN keys match API ids literally.
|
||||
static std::string normalizeCardNumber(std::string_view setNo);
|
||||
|
||||
// CDN preview URL for a normalized card id (.jpg — wxImage registers
|
||||
// JPEG/PNG only; digimoncard.io also serves .webp but we cannot decode it).
|
||||
static std::string buildImageUrl(std::string_view setNo);
|
||||
|
||||
// digimoncard.io search URL: n= / pack= / series= / optional card=.
|
||||
// setName is the pack display name (Set.name), not the slug id.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo);
|
||||
|
||||
// Parse a digimoncard.io search.php body into a CDN image URL for the
|
||||
// first exact name match (optional pack filter applied by the request).
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName,
|
||||
std::string_view wantedSetNo = {});
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99GameModule: Digimon Digi-Battle (1999 English) via digimoncard.io.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99GameModule final : public IGameModule {
|
||||
public:
|
||||
explicit DigiBattle99GameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::DigiBattle99; }
|
||||
[[nodiscard]] std::string dirName() const override { return "digibattle99"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
DigiBattle99SetSource setSource_;
|
||||
DigiBattle99CardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
|
||||
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
|
||||
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
|
||||
// The same payload also builds the set-completion catalog (parseCatalog).
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint =
|
||||
"https://digimoncard.io/api-public/search.php?"
|
||||
"series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc";
|
||||
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
DigiBattle99SetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit DigiBattle99SetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// One HTTP round-trip producing both the set list and the pack catalog.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
|
||||
|
||||
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
|
||||
static std::string slugifyPackName(std::string_view packName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,17 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
|
||||
// TCG. Calls the Pokemon TCG search endpoint at
|
||||
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
|
||||
// and returns `data[0].images.large` (with `images.small` as a graceful
|
||||
// fallback). Mirrors the established `getImage` flow in
|
||||
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
|
||||
// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
|
||||
// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
|
||||
// set-detail name match for auto-detect. Image URLs append /high.png (wxImage
|
||||
// decodes PNG, not webp).
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
@@ -19,24 +18,57 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit PokemonCardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Strip everything after the first '/' (e.g. "4/102" -> "4").
|
||||
static std::string normalizeCollectorNumber(std::string_view setNo);
|
||||
|
||||
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string name;
|
||||
std::string imageBase;
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
|
||||
// for the first matching card. Prefers `images.large`, falls back to
|
||||
// `images.small`. Errors are classified:
|
||||
// - JSON parse failure or missing/non-array `data` => Transient.
|
||||
// - Empty `data` array or missing image variants => NotFound.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseResponse(const std::string& body);
|
||||
parseCardByIdResponse(const std::string& body);
|
||||
|
||||
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseSearchResponse(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
// Parse TCGdex card-by-id JSON into print metadata (name + localId + rarity).
|
||||
static Result<AutoDetectedPrint> parsePrintFromCardById(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
// Sync Pokemon collection cards against freshly fetched set lists:
|
||||
// - West: canonicalize legacy pokemontcg set ids, then refresh name/date
|
||||
// - Asia: refresh name/date when the set id is present in the Asia list
|
||||
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Mutates cards in place. Returns how many cards changed at least one set field.
|
||||
[[nodiscard]] std::size_t syncPokemonCollectionSets(
|
||||
std::vector<PokemonCard>& cards,
|
||||
const std::vector<Set>& westSets,
|
||||
const std::vector<Set>& asiaSets);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
|
||||
// and card preview source, both backed by api.pokemontcg.io/v2.
|
||||
// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en).
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
|
||||
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
|
||||
// response into our `Set` domain type, and sorts by release date ascending.
|
||||
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
|
||||
// so no rewriting is needed (unlike Scryfall's `released_at`).
|
||||
// Behavior matches `pokemon/set_services.rs::update_sets`.
|
||||
// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
|
||||
// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
|
||||
// dates and set-completion checklists come from per-set detail GETs.
|
||||
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
PokemonSetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit PokemonSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
// List + per-set detail (cards + release date) for the offline checklist.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids.
|
||||
// Identity when the id is already TCGdex (or unknown). Asia set ids must not
|
||||
// be passed through this helper.
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids
|
||||
// and ids that already match TCGdex are returned unchanged.
|
||||
[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCardPreviewSource: TCGdex ja localId-based preview + variants.
|
||||
// Image URLs use /high.png (wxImage decodes PNG/JPEG, not webp).
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
JapanesePokemonCardPreviewSource(IHttpClient& http,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
static std::string normalizeLocalId(std::string_view setNo);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
static std::string buildCardUrl(std::string_view setId, std::string_view localId);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
// Parse set-detail body; optionally filter by name (EN catalog / JA) and/or localId.
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string nameJa;
|
||||
std::string imageBase; // empty when TCGdex has no scan
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseCardImageUrl(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Catalog-only Auto-detect when TCGdex has no set detail (theme decks, etc.).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectPrintVariantsFromCatalog(std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Reverse lookup: set + localId → name via catalog (no HTTP).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNoFromCatalog(std::string_view setId,
|
||||
std::string_view localId,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Parse TCGdex JA card-by-id JSON into print metadata.
|
||||
static Result<AutoDetectedPrint> parsePrintFromCardResponse(const std::string& body);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonEnCatalog - bundled English name layer for Japanese Pokémon.
|
||||
// Loaded from assets/pokemon_jp_en_catalog.json (generated offline). Missing
|
||||
// entries fall through to TCGdex Japanese names at runtime.
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonSetEnInfo {
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string releaseDate; // YYYY/MM/DD when known; may be empty
|
||||
};
|
||||
|
||||
struct JapanesePokemonPrintEnInfo {
|
||||
std::string setId;
|
||||
std::string localId;
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string nameEnSource; // bulbapedia | species-table | manual
|
||||
// Classic JA gap-fill when TCGdex has no CDN scan (optional).
|
||||
std::string imageUrl; // explicit HTTPS URL, preferred when set
|
||||
std::string tcgplayerId; // TCGPlayer product id → product-images CDN
|
||||
};
|
||||
|
||||
class JapanesePokemonEnCatalog {
|
||||
public:
|
||||
[[nodiscard]] static Result<JapanesePokemonEnCatalog>
|
||||
parse(const std::string& jsonBody);
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept {
|
||||
return sets_.empty() && printsByKey_.empty();
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonSetEnInfo>
|
||||
findSet(std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonPrintEnInfo>
|
||||
findPrint(std::string_view setId, std::string_view localId) const;
|
||||
|
||||
// Case-insensitive match of nameEn or nameJa within a set.
|
||||
// Also matches qualified English titles: wanted "Mewtwo" hits
|
||||
// "Mewtwo (CoroCoro promo)" (prefix + " (").
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
findPrintsByName(std::string_view setId, std::string_view cardName) const;
|
||||
|
||||
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
|
||||
|
||||
// All prints for a set (catalog gap-fill / set-completion checklists).
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
printsForSet(std::string_view setId) const;
|
||||
|
||||
// TCGPlayer product-image CDN URL for classic JA gap-fill.
|
||||
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
|
||||
|
||||
// Prefer imageUrl; else build from tcgplayerId; else empty.
|
||||
[[nodiscard]] static std::string previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print);
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, JapanesePokemonSetEnInfo> sets_;
|
||||
std::unordered_map<std::string, JapanesePokemonPrintEnInfo> printsByKey_;
|
||||
// setId -> print keys for name scans
|
||||
std::unordered_map<std::string, std::vector<std::string>> printKeysBySet_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonGameModule: Japanese Pokémon TCG via TCGdex ja + EN catalog.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog = {});
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::JapanesePokemon; }
|
||||
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Pokemon (Japan)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
[[nodiscard]] const JapanesePokemonEnCatalog& catalog() const noexcept {
|
||||
return catalog_;
|
||||
}
|
||||
|
||||
private:
|
||||
JapanesePokemonEnCatalog catalog_;
|
||||
JapanesePokemonSetSource setSource_;
|
||||
JapanesePokemonCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
|
||||
// dates and set-completion checklists. English display names come from
|
||||
// JapanesePokemonEnCatalog when present.
|
||||
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
PokemonSetCatalog catalog;
|
||||
};
|
||||
|
||||
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// List + per-set detail (cards + release date) + EN catalog gap-fill.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
void augmentCachedSets(std::vector<Set>& sets) const override;
|
||||
|
||||
// Pure parsers for hermetic tests.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static bool shouldExcludeSetId(std::string_view setId) noexcept;
|
||||
static std::string applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
// Build one pack checklist from a set-detail body, then gap-fill from catalog.
|
||||
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set,
|
||||
const JapanesePokemonEnCatalog& enCatalog);
|
||||
|
||||
// Catalog-only pack (classic products with no TCGdex detail).
|
||||
static PokemonSetCatalogPack catalogPackFromEnCatalog(
|
||||
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
|
||||
|
||||
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
|
||||
static void appendMissingClassicProducts(std::vector<Set>& sets);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,8 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -25,6 +28,9 @@ namespace ccm {
|
||||
// that endpoint returns a richer set listing (with rarities and release
|
||||
// dates) than Yugipedia, and we don't need image data for it.
|
||||
//
|
||||
// Reverse lookup (set + setNo → name) uses the offline set-completion catalog
|
||||
// written by Sets → Update Yu-Gi-Oh! (`YuGiOhSetCatalogService`).
|
||||
//
|
||||
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
|
||||
// stored Language. Localized scans are intentionally not queried so the user
|
||||
// sees a consistent, well-stocked gallery (EN scans are the most complete).
|
||||
@@ -32,6 +38,12 @@ class YuGiOhCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit YuGiOhCardPreviewSource(IHttpClient& http);
|
||||
|
||||
// Optional offline catalog for set+setNo → name reverse lookup. When null
|
||||
// or empty, detectVariantsBySetNo returns a clear "Update Sets" error.
|
||||
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
|
||||
catalogStore_ = catalogStore;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
@@ -43,6 +55,12 @@ public:
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// ---- Yugipedia helpers (image preview path) ----------------------------
|
||||
|
||||
// Build the list of candidate Yugipedia file names to try, in priority
|
||||
@@ -116,8 +134,29 @@ public:
|
||||
std::string_view preferredSetName,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
// Offline reverse lookup against a set-completion catalog. `setId` is the
|
||||
// pack's set code (e.g. "LOB"); `setNo` may be digits ("005") or a full
|
||||
// collector code ("LOB-005" / "LOB-EN005").
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNoFromCatalog(const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// YGOPRODeck cardset= dump filtered by collector digits (HTTP fallback when
|
||||
// the offline catalog is missing or has no match).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNoFromCardset(const std::string& body,
|
||||
std::string_view preferredSetName,
|
||||
std::string_view setNo);
|
||||
|
||||
static std::string buildCardsetOnlyUrl(std::string_view setName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
IHttpClient& http_;
|
||||
YuGiOhSetCatalogService* catalogStore_{nullptr};
|
||||
// Cached offline catalog so reverse auto-detect does not re-parse a
|
||||
// multi-MB JSON file on every button click.
|
||||
mutable std::optional<YuGiOhSetCatalog> catalogCache_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
@@ -17,6 +18,15 @@ public:
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
// Wire offline set catalog for set+setNo → name reverse auto-detect.
|
||||
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
|
||||
previewSource_.setCatalogService(catalogStore);
|
||||
}
|
||||
|
||||
[[nodiscard]] YuGiOhCardPreviewSource& previewSource() noexcept {
|
||||
return previewSource_;
|
||||
}
|
||||
|
||||
private:
|
||||
YuGiOhSetSource setSource_;
|
||||
YuGiOhCardPreviewSource previewSource_;
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
|
||||
// Sets come from cardsets.php; the set-completion catalog is built from the
|
||||
// unfiltered cardinfo.php dump (card_sets[] per card).
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
|
||||
static constexpr const char* kCardInfoEndpoint =
|
||||
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
YuGiOhSetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit YuGiOhSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
|
||||
// the pack checklist catalog.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
|
||||
// Build the offline checklist from a cardinfo.php body, resolving pack
|
||||
// ids against the already-parsed sets list (by set_name → Set.id).
|
||||
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
|
||||
const std::vector<Set>& sets);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiCardPreviewSource: Yugipedia pageimages + SMW ask for Bandai
|
||||
// Carddass previews and auto-detect (by English name or Bandai number).
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
explicit YuGiOhBandaiCardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) override;
|
||||
|
||||
// Prefer "<Name> (Bandai)" / English / Sealdass page depending on setId.
|
||||
static std::string preferredPageTitle(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
static std::string buildPageImagesUrl(std::string_view pageTitle);
|
||||
|
||||
static std::string buildAskByNameUrl(std::string_view englishName);
|
||||
|
||||
static std::string buildAskByNumberUrl(std::string_view setNo);
|
||||
|
||||
// True for Jump/Toei promo codes (J1, TA2, …). Yugipedia's SMW
|
||||
// `Bandai number` property is numeric-only, so these must use the
|
||||
// promotional gallery instead of `action=ask`.
|
||||
[[nodiscard]] static bool isAlphanumericPromoNumber(std::string_view setNo);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePromoGalleryResponse(const std::string& body,
|
||||
std::string_view wantedSetNo);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parsePageImagesResponse(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parseAskResponse(const std::string& body,
|
||||
std::string_view preferredSetId,
|
||||
std::string_view wantedSetNo = {});
|
||||
|
||||
static AutoDetectedPrint enrichPrint(AutoDetectedPrint print,
|
||||
std::string_view pageTitle);
|
||||
|
||||
private:
|
||||
Result<std::string, PreviewLookupError> fetchPageImage(std::string_view pageTitle);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> askByName(std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiGameModule: Bandai Carddass via Yugipedia.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit YuGiOhBandaiGameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOhBandai; }
|
||||
[[nodiscard]] std::string dirName() const override { return "yugiohbandai"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
YuGiOhBandaiSetSource& bandaiSetSource() noexcept { return setSource_; }
|
||||
|
||||
private:
|
||||
YuGiOhBandaiSetSource setSource_;
|
||||
YuGiOhBandaiCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiSetSource: hardcoded Bandai set manifest + Yugipedia gallery
|
||||
// wikitext catalogs for set completion.
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiSetSource final : public ISetSource {
|
||||
public:
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
};
|
||||
|
||||
struct SetManifestEntry {
|
||||
const char* id;
|
||||
const char* name;
|
||||
const char* releaseDate; // YYYY/MM/DD
|
||||
const char* galleryPage; // Yugipedia page title (may be shared)
|
||||
// For the shared promo gallery: keep cards whose setNo starts with
|
||||
// this prefix (empty = keep all from that page into this pack).
|
||||
const char* setNoPrefix;
|
||||
};
|
||||
|
||||
explicit YuGiOhBandaiSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
[[nodiscard]] static const std::vector<SetManifestEntry>& setManifest();
|
||||
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& /*unused*/);
|
||||
|
||||
// Parse one gallery wikitext body into checklist cards.
|
||||
static Result<std::vector<YuGiOhBandaiCatalogCard>>
|
||||
parseGalleryWikitext(const std::string& wikitext);
|
||||
|
||||
// Map a Bandai number string to a set id (ban1/ban2/ban3/promos/sealdass).
|
||||
static std::string setIdForNumber(std::string_view setNo);
|
||||
|
||||
static std::string setNameForId(std::string_view setId);
|
||||
|
||||
// Normalize printed numbers: strip leading zeros on pure-decimal values;
|
||||
// uppercase letter prefixes (j1 → J1). Sealdass stays unpadded decimal.
|
||||
static std::string normalizeCardNumber(std::string_view setNo);
|
||||
|
||||
static std::string expandRarityCode(std::string_view code);
|
||||
|
||||
static std::string buildGalleryParseUrl(std::string_view pageTitle);
|
||||
|
||||
static std::string englishNameFromGalleryTitle(std::string_view pageTitle);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
@@ -23,7 +24,21 @@ namespace ccm {
|
||||
// only fires one outbound request at a time anyway.
|
||||
class CprHttpClient final : public IHttpClient {
|
||||
public:
|
||||
struct RawResponse {
|
||||
bool transportError{false};
|
||||
std::string transportMessage;
|
||||
int statusCode{0};
|
||||
std::string body;
|
||||
};
|
||||
|
||||
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
|
||||
using RawGetExecutor = std::function<RawResponse(std::string_view)>;
|
||||
|
||||
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
CprHttpClient(GetExecutor executor,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
CprHttpClient(RawGetExecutor rawExecutor,
|
||||
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
|
||||
~CprHttpClient() override;
|
||||
|
||||
Result<std::string> get(std::string_view url) override;
|
||||
@@ -31,6 +46,8 @@ public:
|
||||
private:
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::unique_ptr<cpr::Session> session_;
|
||||
GetExecutor executor_;
|
||||
RawGetExecutor rawExecutor_;
|
||||
std::mutex sessionMutex_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
|
||||
// JsonSetRepository: persists vector<Set> under `<dataStorage>/<dirName>/`.
|
||||
// Most games use `sets.json`. Pokemon West/Asia share dir `pokemon` with
|
||||
// `sets-west.json` / `sets-asia.json` (migrate-on-load from legacy paths).
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
@@ -27,6 +29,8 @@ private:
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
|
||||
[[nodiscard]] std::filesystem::path legacySetsPath(Game game) const;
|
||||
[[nodiscard]] Result<std::vector<Set>> parseSetsText(const std::string& text) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -19,6 +19,12 @@ namespace ccm {
|
||||
struct AutoDetectedPrint {
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
// Optional fields used by games that resolve set/name/language during
|
||||
// auto-detect (e.g. Yu-Gi-Oh! Bandai). Existing games leave them empty.
|
||||
std::string name;
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::string language; // Language enum spelling when known ("Japanese" / "English")
|
||||
};
|
||||
|
||||
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
|
||||
@@ -79,6 +85,23 @@ public:
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Print variant listing not supported by this game.");
|
||||
}
|
||||
|
||||
// Optional lookup by set + collector / Bandai number (fills name + rarity).
|
||||
// `setId` uses the same meaning as detectPrintVariants for the game
|
||||
// (set id for Pokémon/Bandai; set display name for Digi-Battle; set code
|
||||
// id for Yu-Gi-Oh! catalog reverse lookup).
|
||||
virtual Result<AutoDetectedPrint> detectBySetNo(std::string_view /*setId*/,
|
||||
std::string_view /*setNo*/) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Detect-by-number not supported by this game.");
|
||||
}
|
||||
|
||||
virtual Result<std::vector<AutoDetectedPrint>>
|
||||
detectVariantsBySetNo(std::string_view /*setId*/,
|
||||
std::string_view /*setNo*/) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Detect-by-number variants not supported by this game.");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// ISetRepository - persistence port for the cached `sets.json` of a game.
|
||||
// ISetRepository - persistence port for the cached set list of a game.
|
||||
// Typical layout: `<dataStorage>/<dirName>/sets.json`. Pokemon West/Asia use
|
||||
// `sets-west.json` / `sets-asia.json` under the shared `pokemon/` directory.
|
||||
// Stored as a flat list to mirror the original Rust file layout.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
// * An empty filter matches every row, exactly as in JS where every string
|
||||
// `.includes("")` returns true.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
#include <string_view>
|
||||
@@ -34,11 +37,23 @@ namespace ccm {
|
||||
std::string_view filter);
|
||||
|
||||
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
|
||||
// name, set.name, setNo, language, condition, amount, note.
|
||||
// name, set.name, setNo, language, condition, amount, note, region.
|
||||
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||
std::string_view filter);
|
||||
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Digi-Battle mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Bandai: name, set.name, setNo, rarity, language, condition, amount, note.
|
||||
[[nodiscard]] bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -32,7 +34,9 @@ namespace ccm {
|
||||
class CardPreviewService {
|
||||
public:
|
||||
explicit CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache = nullptr);
|
||||
IPreviewByteCache* persistentCache = nullptr,
|
||||
IFileSystem* fs = nullptr,
|
||||
std::filesystem::path assetRoot = {});
|
||||
|
||||
// Register a game module's preview source. Calling this with a module
|
||||
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
||||
@@ -77,6 +81,15 @@ public:
|
||||
std::string_view name,
|
||||
std::string_view setId);
|
||||
|
||||
Result<AutoDetectedPrint> detectBySetNo(Game game,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
|
||||
Game game,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
|
||||
// Download image bytes from a fully-qualified URL without going through
|
||||
// per-game preview-source resolution. Cached by URL (same LRU bound).
|
||||
Result<std::string> fetchImageBytesByUrl(std::string_view url);
|
||||
@@ -96,6 +109,9 @@ private:
|
||||
|
||||
Result<std::string> fetchAndCache(const std::string& cacheKey,
|
||||
std::string_view url);
|
||||
Result<std::string, PreviewLookupError> fetchAssetAndCache(
|
||||
const std::string& cacheKey,
|
||||
std::string_view assetUrl);
|
||||
|
||||
// Returns the kind of in-memory cache entry for `key`. On Hit the
|
||||
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
|
||||
@@ -107,6 +123,8 @@ private:
|
||||
|
||||
IHttpClient& http_;
|
||||
IPreviewByteCache* persistentCache_{nullptr};
|
||||
IFileSystem* fs_{nullptr};
|
||||
std::filesystem::path assetRoot_;
|
||||
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
||||
|
||||
// LRU: list holds entries in MRU-first order; map points at list nodes
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
// UI relies on it so successive clicks on different columns compose predictably
|
||||
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
#include <vector>
|
||||
@@ -59,6 +62,49 @@ enum class YuGiOhSortColumn {
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Rarity,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Digi-Battle mirrors Pokemon columns (setNo is filter-only, not a sort column).
|
||||
enum class DigiBattle99SortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
enum class YuGiOhBandaiSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
SetNo,
|
||||
Rarity,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon columns.
|
||||
enum class JapanesePokemonSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
@@ -73,5 +119,14 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending);
|
||||
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
bool ascending);
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending);
|
||||
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
|
||||
YuGiOhBandaiSortColumn column,
|
||||
bool ascending);
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -80,6 +80,15 @@ public:
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
|
||||
Result<void> saveAll(Game game, std::vector<TCard> cards) {
|
||||
Map map;
|
||||
for (auto& card : cards) {
|
||||
map.insert_or_assign(card.id, std::move(card));
|
||||
}
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Remove the card with the given id. Also deletes any associated images
|
||||
// via the IImageStore (best-effort - image removal failures are logged in
|
||||
// the error string but the card itself is still purged from the JSON).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
|
||||
// the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<DigiBattle99SetCatalog> load() const;
|
||||
Result<void> save(const DigiBattle99SetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
|
||||
// inflate the numerator. An optional languageFilter restricts ownership to
|
||||
// cards of that language (packs with zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99SetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct DigiBattle99ChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection, in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped. When languageFilter is
|
||||
// set, only cards of that language count toward ownership.
|
||||
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
// When languageFilter is set, only cards of that language count as owned.
|
||||
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
|
||||
// pokemon/set-catalog-asia.json under the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<PokemonSetCatalog> load(PokemonRegion region) const;
|
||||
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists(PokemonRegion region) const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Pokemon set-completion progress and per-set checklists.
|
||||
// Ownership requires matching PokemonRegion for the pack (West vs Asia),
|
||||
// matching set.id, and a normalized collector number / localId. Duplicates /
|
||||
// amount / holo / firstEdition do not inflate the numerator. Optional
|
||||
// regionFilter and languageFilter restrict which cards count (packs with
|
||||
// zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonSetCompletionProgress {
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::string releaseDate; // YYYY/MM/DD from owned cards; may be empty
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct PokemonChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection (optionally region-scoped),
|
||||
// in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
|
||||
std::optional<PokemonRegion> regionFilter = std::nullopt);
|
||||
|
||||
// Distinct regions that have ≥1 owned card matching a catalog pack.
|
||||
[[nodiscard]] std::vector<PokemonRegion>
|
||||
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog);
|
||||
|
||||
// Packs where the collection owns ≥1 matching card, ordered by releaseDate
|
||||
// then setName then region. When regionFilter is set, only that region's
|
||||
// catalog/cards count.
|
||||
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
|
||||
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
std::optional<PokemonRegion> regionFilter = std::nullopt,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
[[nodiscard]] std::vector<PokemonChecklistEntry>
|
||||
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
PokemonRegion region,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -27,6 +27,10 @@ public:
|
||||
// repository, and return the new list.
|
||||
Result<std::vector<Set>> updateSets(Game game);
|
||||
|
||||
// Persist an already-fetched set list (no HTTP). Used when a game-specific
|
||||
// Update Sets path fetches sets + side payloads in one round-trip.
|
||||
Result<void> saveSets(Game game, const std::vector<Set>& sets);
|
||||
|
||||
// Cached read; returns an error if no local data exists yet.
|
||||
Result<std::vector<Set>> getSets(Game game);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhBandaiSetCatalogService: load/save yugiohbandai/set-catalog.json.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhBandaiSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
YuGiOhBandaiSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<YuGiOhBandaiSetCatalog> load() const;
|
||||
Result<void> save(const YuGiOhBandaiSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Bandai set-completion progress and per-set checklists.
|
||||
// Ownership keys on (set.id, normalized setNo). Never name-only.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhBandaiSetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct YuGiOhBandaiChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
std::string rarity;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
[[nodiscard]] std::vector<Language>
|
||||
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection);
|
||||
|
||||
[[nodiscard]] std::vector<YuGiOhBandaiSetCompletionProgress>
|
||||
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
|
||||
const YuGiOhBandaiSetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
[[nodiscard]] std::vector<YuGiOhBandaiChecklistEntry>
|
||||
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
|
||||
const YuGiOhBandaiSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
|
||||
// configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<YuGiOhSetCatalog> load() const;
|
||||
Result<void> save(const YuGiOhSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
|
||||
// amount / rarity / firstEdition do not inflate the numerator. An optional
|
||||
// languageFilter restricts ownership to cards of that language (packs with
|
||||
// zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhSetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct YuGiOhChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection, in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped. When languageFilter is
|
||||
// set, only cards of that language count toward ownership.
|
||||
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
|
||||
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
// When languageFilter is set, only cards of that language count as owned.
|
||||
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
|
||||
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// ASCII-only tolower for sort/filter parity with the legacy TS path:
|
||||
// String.prototype.toLowerCase() on English/German/etc. card metadata behaves
|
||||
// identically for this byte range.
|
||||
[[nodiscard]] inline std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
// Shared rule for bidirectional Set # Auto detect (name ↔ set number):
|
||||
// when both fields are filled, the field the user last edited is the lookup key.
|
||||
|
||||
namespace ccm {
|
||||
|
||||
enum class CardLookupEditField { None, Name, SetNo };
|
||||
|
||||
// Returns true when Auto detect should run setNo → name (reverse).
|
||||
// `nameEmpty` / `setNoEmpty` are already trimmed/normalized by the caller.
|
||||
// When both are empty the result is false (caller shows a validation message).
|
||||
// When only one is filled, that direction wins. When both are filled, SetNo
|
||||
// wins only if it was the last edited lookup field; otherwise Name wins
|
||||
// (including `None`, matching the historical default).
|
||||
[[nodiscard]] inline bool preferDetectBySetNo(bool nameEmpty,
|
||||
bool setNoEmpty,
|
||||
CardLookupEditField lastEdited) noexcept {
|
||||
if (nameEmpty) return !setNoEmpty;
|
||||
if (setNoEmpty) return false;
|
||||
return lastEdited == CardLookupEditField::SetNo;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Shared classification for raw HTTP GET outcomes (transport vs status vs OK).
|
||||
// `CprHttpClient::get` delegates here so doctest can exercise the branches
|
||||
// without touching libcpr or the network stack.
|
||||
[[nodiscard]] inline Result<std::string> mapHttpGetResponse(bool curlTransportError,
|
||||
std::string_view curlErrorMessage,
|
||||
long httpStatusCode,
|
||||
std::string responseBody,
|
||||
std::string_view requestUrl) {
|
||||
if (curlTransportError) {
|
||||
return Result<std::string>::err(std::string("HTTP error: ") +
|
||||
std::string(curlErrorMessage));
|
||||
}
|
||||
if (httpStatusCode < 200 || httpStatusCode >= 300) {
|
||||
return Result<std::string>::err(
|
||||
"HTTP " + std::to_string(httpStatusCode) + " from " +
|
||||
std::string(requestUrl));
|
||||
}
|
||||
return Result<std::string>::ok(std::move(responseBody));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Needed because cpr does not encode the URL
|
||||
// string passed to IHttpClient::get.
|
||||
[[nodiscard]] inline std::string rfc3986PercentEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
// Natural (alphanumeric) ordering for collector / set numbers.
|
||||
// Digit runs compare as integers so "2" < "10" < "100"; non-digit runs use
|
||||
// ordinary string order (e.g. "SWSH001" < "SWSH002").
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// strcmp-style: <0 if a < b, 0 if equal (after natural + lex tie-break), >0 if a > b.
|
||||
[[nodiscard]] int compareSetNoNatural(std::string_view a, std::string_view b) noexcept;
|
||||
|
||||
} // namespace ccm
|
||||
@@ -48,12 +48,39 @@ namespace ccm {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Digits from a full set code (after '-') or from a digits-only Set # field.
|
||||
[[nodiscard]] inline std::string ygoCollectorDigitsFromInput(std::string_view raw) {
|
||||
const std::string_view s = trimAsciiSpaces(raw);
|
||||
if (s.find('-') != std::string_view::npos) return ygoCollectorDigitsOnly(s);
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (unsigned char c : s) {
|
||||
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string ygoDigitsStripLeadingZeros(std::string digits) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < digits.size() && digits[i] == '0') ++i;
|
||||
if (i > 0) digits.erase(0, i);
|
||||
return digits;
|
||||
}
|
||||
|
||||
// True when both designate the same collector number, ignoring leading zeros
|
||||
// ("5" == "005") and accepting either a full set code or digits-only input.
|
||||
[[nodiscard]] inline bool ygoCollectorDigitsEqual(std::string_view a,
|
||||
std::string_view b) {
|
||||
return ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(a)) ==
|
||||
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(b));
|
||||
}
|
||||
|
||||
// True when both strings designate the same printed slot: same abbreviation
|
||||
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
|
||||
// extracted from everything after that dash.
|
||||
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
|
||||
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
|
||||
return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b);
|
||||
return ygoCollectorDigitsEqual(a, b);
|
||||
}
|
||||
|
||||
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
|
||||
@@ -69,4 +96,31 @@ namespace ccm {
|
||||
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
|
||||
}
|
||||
|
||||
// Canonical short-form for Yu-Gi-Oh rarities used by the overview table.
|
||||
// Returns empty when rarity is unknown.
|
||||
[[nodiscard]] inline std::string ygoRarityShortCode(std::string_view rarity) {
|
||||
std::string normalized;
|
||||
normalized.reserve(rarity.size());
|
||||
for (unsigned char c : rarity) {
|
||||
if (std::isspace(c) != 0) continue;
|
||||
if (c == '\'' || c == '`' || c == '-') continue;
|
||||
normalized.push_back(static_cast<char>(std::tolower(c)));
|
||||
}
|
||||
|
||||
if (normalized == "common") return "C";
|
||||
if (normalized == "rare") return "R";
|
||||
if (normalized == "superrare") return "SR";
|
||||
if (normalized == "ultrarare") return "UR";
|
||||
if (normalized == "secretrare") return "ScR";
|
||||
if (normalized == "quartercenturysecretrare") return "QCScR";
|
||||
if (normalized == "qcsr") return "QCScR";
|
||||
if (normalized == "starlightrare") return "StR";
|
||||
if (normalized == "collectorsrare") return "CR";
|
||||
if (normalized == "ghostrare") return "GR";
|
||||
if (normalized == "ultimaterare") return "UtR";
|
||||
if (normalized == "platinumsecretrare") return "PlScR";
|
||||
if (normalized == "prismaticsecretrare") return "PScR";
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// Resolves a Yu-Gi-Oh! product code (YGOPRODeck `set_code`, stored as `Set.id`)
|
||||
// against a cached set list. Used by the Yu-Gi-Oh! edit dialog "set code" mode.
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhSetShorthandLookup {
|
||||
enum class Kind { Unique, NotFound, Ambiguous };
|
||||
|
||||
Kind kind{Kind::NotFound};
|
||||
std::size_t index{0};
|
||||
};
|
||||
|
||||
[[nodiscard]] inline std::string normalizeYuGiOhSetIdForLookup(std::string_view id) {
|
||||
std::string out;
|
||||
out.reserve(id.size());
|
||||
for (unsigned char uch : id) {
|
||||
out.push_back(static_cast<char>(std::tolower(uch)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline std::string_view trimAsciiWhitespace(std::string_view s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
[[nodiscard]] inline YuGiOhSetShorthandLookup lookupYuGiOhSetByShorthand(
|
||||
std::string_view query, const std::vector<Set>& sets) {
|
||||
const std::string_view trimmed = trimAsciiWhitespace(query);
|
||||
if (trimmed.empty()) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
|
||||
}
|
||||
const std::string qNorm = normalizeYuGiOhSetIdForLookup(trimmed);
|
||||
|
||||
std::size_t firstIdx = 0;
|
||||
int matchCount = 0;
|
||||
for (std::size_t i = 0; i < sets.size(); ++i) {
|
||||
if (normalizeYuGiOhSetIdForLookup(sets[i].id) == qNorm) {
|
||||
if (matchCount == 0) firstIdx = i;
|
||||
++matchCount;
|
||||
if (matchCount > 1) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::Ambiguous, 0};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchCount == 1) {
|
||||
return {YuGiOhSetShorthandLookup::Kind::Unique, firstIdx};
|
||||
}
|
||||
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) {
|
||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||
j.at("dataStorage").get_to(c.dataStorage);
|
||||
j.at("defaultGame").get_to(c.defaultGame);
|
||||
// JapanesePokemon was folded into Pokemon (West/Asia region). Coerce so
|
||||
// older config.json files keep a valid user-facing default game.
|
||||
if (c.defaultGame == Game::JapanesePokemon) {
|
||||
c.defaultGame = Game::Pokemon;
|
||||
}
|
||||
c.theme = j.value("theme", Theme::Light);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
|
||||
std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
+99
-35
@@ -3,29 +3,48 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#define CCM_UNREACHABLE() __builtin_unreachable()
|
||||
#else
|
||||
#define CCM_UNREACHABLE() ((void)0)
|
||||
#endif
|
||||
|
||||
namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::DigiBattle99: return "DigiBattle99";
|
||||
case Game::YuGiOhBandai: return "YuGiOhBandai";
|
||||
case Game::JapanesePokemon: return "JapanesePokemon";
|
||||
}
|
||||
return "Magic";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(PokemonRegion r) noexcept {
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return "West";
|
||||
case PokemonRegion::Asia: return "Asia";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
switch (l) {
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::Chinese: return "Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::SimplifiedChinese: return "S-Chinese";
|
||||
case Language::TraditionalChinese: return "T-Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Korean: return "Korean";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
return "English";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Condition c) noexcept {
|
||||
@@ -38,7 +57,7 @@ std::string_view to_string(Condition c) noexcept {
|
||||
case Condition::Played: return "Played";
|
||||
case Condition::Poor: return "Poor";
|
||||
}
|
||||
return "Mint";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Theme t) noexcept {
|
||||
@@ -46,25 +65,38 @@ std::string_view to_string(Theme t) noexcept {
|
||||
case Theme::Light: return "Light";
|
||||
case Theme::Dark: return "Dark";
|
||||
}
|
||||
return "Light";
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
||||
if (s == "YuGiOhBandai") return Game::YuGiOhBandai;
|
||||
if (s == "JapanesePokemon") return Game::JapanesePokemon;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept {
|
||||
if (s == "West") return PokemonRegion::West;
|
||||
if (s == "Asia") return PokemonRegion::Asia;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "Chinese") return Language::Chinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "S-Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "T-Chinese") return Language::TraditionalChinese;
|
||||
// Legacy single Chinese spelling → Simplified.
|
||||
if (s == "Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Korean") return Language::Korean;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -85,15 +117,18 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::array<Game, 3>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
|
||||
const std::array<Game, 5>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 5> v{
|
||||
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::YuGiOhBandai,
|
||||
Game::DigiBattle99};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Language, 8>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 8> v{
|
||||
const std::array<Language, 10>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 10> v{
|
||||
Language::English, Language::German, Language::French, Language::Spanish,
|
||||
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
|
||||
Language::Italian, Language::SimplifiedChinese, Language::TraditionalChinese,
|
||||
Language::Japanese, Language::Korean, Language::Russian
|
||||
};
|
||||
return v;
|
||||
}
|
||||
@@ -111,16 +146,45 @@ const std::array<Theme, 2>& allThemes() noexcept {
|
||||
return v;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
|
||||
static constexpr std::array<Language, 6> kWest{
|
||||
Language::English, Language::German, Language::French,
|
||||
Language::Spanish, Language::Italian, Language::Russian};
|
||||
static constexpr std::array<Language, 4> kAsia{
|
||||
Language::Japanese, Language::SimplifiedChinese,
|
||||
Language::TraditionalChinese, Language::Korean};
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return kWest;
|
||||
case PokemonRegion::Asia: return kAsia;
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
return kWest;
|
||||
}
|
||||
|
||||
Game pokemonBackendGame(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Game::JapanesePokemon : Game::Pokemon;
|
||||
}
|
||||
|
||||
Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Language::Japanese : Language::English;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, PokemonRegion v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
|
||||
void from_json(const nlohmann::json& j, Game& v) {
|
||||
auto parsed = gameFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v) {
|
||||
auto parsed = pokemonRegionFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Language& v) {
|
||||
auto parsed = languageFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
@@ -17,6 +19,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
{"region", c.region},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +37,13 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
// Missing `region` defaults to West so pre-merge West-only files still load.
|
||||
c.region = j.value("region", PokemonRegion::West);
|
||||
// Migrate legacy pokemontcg.io West set ids to TCGdex EN on load so the
|
||||
// next collection save persists canonical ids. Asia ids are untouched.
|
||||
if (c.region == PokemonRegion::West && !c.set.id.empty()) {
|
||||
c.set.id = canonicalizeWestSetId(c.set.id);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"rarity", c.rarity},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("rarity").get_to(c.rarity);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,45 @@
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const YuGiOhBandaiSetCatalogPack* YuGiOhBandaiSetCatalog::findPack(
|
||||
std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}, {"rarity", c.rarity}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
if (j.contains("rarity")) {
|
||||
j.at("rarity").get_to(c.rarity);
|
||||
} else {
|
||||
c.rarity.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -15,7 +15,6 @@ void to_json(nlohmann::json& j, const YuGiOhCard& c) {
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"rarity", c.rarity},
|
||||
{"rarityCode", c.rarityCode},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
@@ -33,8 +32,6 @@ void from_json(const nlohmann::json& j, YuGiOhCard& c) {
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("rarity").get_to(c.rarity);
|
||||
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
|
||||
else c.rarityCode.clear();
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
if (!c.rarity.empty()) j["rarity"] = c.rarity;
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
c.rarity = j.value("rarity", "");
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,305 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool cardInPack(const nlohmann::json& card, std::string_view packName) {
|
||||
if (packName.empty()) return true;
|
||||
if (!card.contains("set_name") || !card.at("set_name").is_array()) return false;
|
||||
for (const auto& pack : card.at("set_name")) {
|
||||
if (pack.is_string() && pack.get<std::string>() == packName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric collector suffix: "ST-01" → "01", "01" → "01", "BO-115" → "115".
|
||||
std::string numericSuffix(std::string_view setNo) {
|
||||
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
|
||||
const auto dash = n.find('-');
|
||||
const std::string_view tail =
|
||||
dash == std::string::npos ? std::string_view{n} : std::string_view{n}.substr(dash + 1);
|
||||
std::string out;
|
||||
out.reserve(tail.size());
|
||||
for (unsigned char c : tail) {
|
||||
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string digits) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < digits.size() && digits[i] == '0') ++i;
|
||||
if (i > 0) digits.erase(0, i);
|
||||
return digits;
|
||||
}
|
||||
|
||||
bool hasAlphabeticPrefix(std::string_view setNo) {
|
||||
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
|
||||
return !n.empty() && std::isalpha(static_cast<unsigned char>(n.front())) != 0;
|
||||
}
|
||||
|
||||
// Exact id match, or digits-only input matched to the numeric suffix with
|
||||
// leading zeros ignored ("1" ↔ "ST-01", but not "ST-11").
|
||||
bool cardNumbersMatch(std::string_view wanted, std::string_view actual) {
|
||||
const std::string a = DigiBattle99CardPreviewSource::normalizeCardNumber(wanted);
|
||||
const std::string b = DigiBattle99CardPreviewSource::normalizeCardNumber(actual);
|
||||
if (a.empty() || b.empty()) return false;
|
||||
if (a == b) return true;
|
||||
// Full id typed (ST-01): require exact normalized equality only.
|
||||
if (hasAlphabeticPrefix(a)) return false;
|
||||
return stripLeadingZeros(numericSuffix(a)) == stripLeadingZeros(numericSuffix(b));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
|
||||
: http_(http) {}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::normalizeCardNumber(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
if (s.empty()) return s;
|
||||
// Uppercase leading alphabetic prefix (ST / BO / MO / Fx-style).
|
||||
std::size_t i = 0;
|
||||
while (i < s.size() && std::isalpha(static_cast<unsigned char>(s[i]))) {
|
||||
s[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
|
||||
++i;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildImageUrl(std::string_view setNo) {
|
||||
const std::string id = normalizeCardNumber(setNo);
|
||||
return std::string(kImageBase) + id + ".jpg";
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
std::string url = "https://digimoncard.io/api-public/search.php?series=";
|
||||
url += rfc3986PercentEncode(kSeries);
|
||||
if (!name.empty()) {
|
||||
url += "&n=";
|
||||
url += rfc3986PercentEncode(name);
|
||||
}
|
||||
if (!setName.empty()) {
|
||||
url += "&pack=";
|
||||
url += rfc3986PercentEncode(setName);
|
||||
}
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
url += "&card=";
|
||||
url += rfc3986PercentEncode(num);
|
||||
}
|
||||
url += "&sort=name&sortdirection=asc";
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err({K::NotFound, j.value("error", std::string{"No cards found."})});
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "digimoncard.io Digi-Battle response is not a JSON array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
const nlohmann::json* chosen = nullptr;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedLower) continue;
|
||||
}
|
||||
chosen = &card;
|
||||
break;
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
const std::string id = normalizeCardNumber(chosen->value("id", ""));
|
||||
if (id.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle card has no id / card number."});
|
||||
}
|
||||
return R::ok(buildImageUrl(id));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
return R::ok(buildImageUrl(num));
|
||||
}
|
||||
if (name.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle preview requires a card name or set number."});
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseImageUrlFromSearch(resp.value(), name);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName,
|
||||
std::string_view wantedSetNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err(j.value("error", std::string{"No cards found."}));
|
||||
}
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
const std::string wantedPack = trim(std::string(setName));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
const std::string wantedNo = normalizeCardNumber(wantedSetNo);
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!cardInPack(card, wantedPack)) continue;
|
||||
AutoDetectedPrint out;
|
||||
out.name = trim(card.value("name", ""));
|
||||
out.setNo = normalizeCardNumber(card.value("id", ""));
|
||||
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
|
||||
if (out.setNo.empty()) continue;
|
||||
// digimoncard.io `card=` is fuzzy (card=1 can return ST-01 and ST-11).
|
||||
// When the user typed a number, keep only exact / zero-padded matches.
|
||||
if (!wantedNo.empty() && !cardNumbersMatch(wantedNo, out.setNo)) continue;
|
||||
collected.push_back(std::move(out));
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
if (!wantedNo.empty()) {
|
||||
return R::err("Could not auto-detect Digi-Battle card name from set number.");
|
||||
}
|
||||
if (!wantedNameLower.empty() && !wantedPack.empty()) {
|
||||
return R::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
if (seen.insert(p.setNo).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectFirstPrint(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
auto list = detectPrintVariants(name, setName);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setName, name);
|
||||
}
|
||||
// Retry name-only; still filter by pack in parsePrintVariants.
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "", "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setName, name);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectBySetNo(
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setName, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect Digi-Battle card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectVariantsBySetNo(
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (trim(std::string(setName)).empty()) return R::err("Select a set first.");
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (num.empty()) return R::err("Card number is empty.");
|
||||
|
||||
const std::string url = buildSearchUrl("", setName, num);
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
auto parsed = parsePrintVariants(resp.value(), setName, "", num);
|
||||
if (parsed && !parsed.value().empty()) return parsed;
|
||||
}
|
||||
// Retry number-only; still filter by pack + exact/padded number.
|
||||
const std::string fallbackUrl = buildSearchUrl("", "", num);
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) {
|
||||
if (resp) return R::err("Could not auto-detect Digi-Battle card name from set number.");
|
||||
return R::err(fallback.error());
|
||||
}
|
||||
return parsePrintVariants(fallback.value(), setName, "", num);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,206 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Curated EN release dates for the vintage Digi-Battle product line.
|
||||
// Series 1 Starter is verified 1999-06-01; other entries use digimoncard.io /
|
||||
// checklist years (day unknown -> YYYY/01/01 or mid-year anchors for ordering).
|
||||
const std::unordered_map<std::string, std::string>& curatedReleaseDates() {
|
||||
static const std::unordered_map<std::string, std::string> kDates{
|
||||
{"Series 1 Starter Set", "1999/06/01"},
|
||||
{"Series 1 Booster Pack", "1999/06/01"},
|
||||
{"Series 2 Booster Pack", "1999/09/01"},
|
||||
{"Series 3 Booster Pack", "2000/01/01"},
|
||||
{"Series 4 Booster Pack", "2000/06/01"},
|
||||
{"Series 5 Booster Pack", "2000/10/01"},
|
||||
{"Series 6 Booster Pack", "2001/01/01"},
|
||||
{"Street Starter Set 1", "2001/01/01"},
|
||||
{"Street Starter Set 2", "2001/02/01"},
|
||||
{"Street Starter Set 3", "2001/03/01"},
|
||||
{"Street Starter Set 4", "2001/04/01"},
|
||||
{"Digimon The Movie Promo Cards", "2000/10/01"},
|
||||
};
|
||||
return kDates;
|
||||
}
|
||||
|
||||
std::string releaseDateForPack(const std::string& packName) {
|
||||
const auto& dates = curatedReleaseDates();
|
||||
const auto it = dates.find(packName);
|
||||
if (it != dates.end()) return it->second;
|
||||
return {};
|
||||
}
|
||||
|
||||
Result<nlohmann::json> parseSearchArray(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return Result<nlohmann::json>::err(
|
||||
j.value("error", std::string{"digimoncard.io set search error"}));
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return Result<nlohmann::json>::err(
|
||||
"digimoncard.io Digi-Battle response is not a JSON array.");
|
||||
}
|
||||
return Result<nlohmann::json>::ok(j);
|
||||
} catch (const std::exception& e) {
|
||||
return Result<nlohmann::json>::err(
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
|
||||
std::string out;
|
||||
out.reserve(packName.size());
|
||||
bool pendingHyphen = false;
|
||||
for (unsigned char ch : packName) {
|
||||
if (std::isalnum(ch)) {
|
||||
if (pendingHyphen && !out.empty()) out.push_back('-');
|
||||
pendingHyphen = false;
|
||||
out.push_back(static_cast<char>(std::tolower(ch)));
|
||||
} else {
|
||||
pendingHyphen = !out.empty();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<std::vector<Set>>::err(arr.error());
|
||||
|
||||
// Preserve first-seen order of pack names, then sort by release date.
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<std::string> packNames;
|
||||
packNames.reserve(16);
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string name = pack.get<std::string>();
|
||||
if (name.empty()) continue;
|
||||
if (seen.insert(name).second) packNames.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Set> out;
|
||||
out.reserve(packNames.size());
|
||||
for (const auto& name : packNames) {
|
||||
Set s;
|
||||
s.id = slugifyPackName(name);
|
||||
s.name = name;
|
||||
s.releaseDate = releaseDateForPack(name);
|
||||
if (s.id.empty()) continue;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
|
||||
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
|
||||
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
|
||||
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
|
||||
|
||||
// pack display name -> (setId, ordered unique cards by first-seen setNo)
|
||||
struct PackBuild {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::unordered_set<std::string> seenNos;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
};
|
||||
std::unordered_map<std::string, PackBuild> byName;
|
||||
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
|
||||
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
|
||||
DigiBattle99CatalogCard card;
|
||||
card.name = entry.at("name").get<std::string>();
|
||||
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
|
||||
entry.at("id").get<std::string>());
|
||||
if (card.setNo.empty()) continue;
|
||||
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string packName = pack.get<std::string>();
|
||||
if (packName.empty()) continue;
|
||||
|
||||
auto& build = byName[packName];
|
||||
if (build.setName.empty()) {
|
||||
build.setName = packName;
|
||||
build.setId = slugifyPackName(packName);
|
||||
}
|
||||
if (build.setId.empty()) continue;
|
||||
if (!build.seenNos.insert(card.setNo).second) continue;
|
||||
build.cards.push_back(card);
|
||||
}
|
||||
}
|
||||
|
||||
DigiBattle99SetCatalog catalog;
|
||||
catalog.packs.reserve(byName.size());
|
||||
for (auto& [_, build] : byName) {
|
||||
if (build.setId.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
DigiBattle99SetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetSource::FetchWithCatalog>
|
||||
DigiBattle99SetSource::fetchAllWithCatalog() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
|
||||
|
||||
auto sets = parseResponse(resp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
auto catalog = parseCatalog(resp.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
|
||||
auto both = fetchAllWithCatalog();
|
||||
if (!both) return Result<std::vector<Set>>::err(both.error());
|
||||
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,40 +1,16 @@
|
||||
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encode all bytes that are not unreserved per RFC 3986
|
||||
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
|
||||
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
|
||||
// which does not URL-encode the URL string we hand it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
// Apply the same name massaging as the legacy query path before sending.
|
||||
std::string sanitizeName(std::string_view name) {
|
||||
std::string s(name);
|
||||
@@ -59,7 +35,8 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
query += sanitized;
|
||||
query += "\" AND set:";
|
||||
query += std::string(setId);
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") +
|
||||
rfc3986PercentEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
|
||||
@@ -1,43 +1,53 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
|
||||
// Magic implementation; kept private so the two can drift independently if a
|
||||
// future API requires it.
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Strip everything after the first '/' in a Pokemon collector number.
|
||||
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
|
||||
// `4/102`. Without this, no API match is found.
|
||||
std::string normalizeNumber(std::string_view setNo) {
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string_view s) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return std::string(s.substr(i));
|
||||
}
|
||||
|
||||
// Exact localId match after slash-normalization, or leading-zero-insensitive
|
||||
// equality ("4" ↔ "04", not "4" ↔ "14").
|
||||
bool localIdsMatch(std::string_view a, std::string_view b) {
|
||||
const std::string na = PokemonCardPreviewSource::normalizeCollectorNumber(a);
|
||||
const std::string nb = PokemonCardPreviewSource::normalizeCollectorNumber(b);
|
||||
if (na.empty() || nb.empty()) return false;
|
||||
if (na == nb) return true;
|
||||
return stripLeadingZeros(na) == stripLeadingZeros(nb);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
@@ -46,58 +56,136 @@ std::string normalizeNumber(std::string_view setNo) {
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
|
||||
if (imageBase.empty()) return {};
|
||||
std::string url(imageBase);
|
||||
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
|
||||
return url + "/high.png";
|
||||
}
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
std::string id = idCanon + "-" + num;
|
||||
return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/en/sets/") +
|
||||
rfc3986PercentEncode(canonicalizeWestSetId(setId));
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
// Build the unencoded query first so the output matches what the Pokemon
|
||||
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
|
||||
std::string query = "name:\"";
|
||||
query += std::string(name);
|
||||
query += "\"";
|
||||
if (!setId.empty()) {
|
||||
query += " set.id:";
|
||||
query += std::string(setId);
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
std::string url = "https://api.tcgdex.net/v2/en/cards?";
|
||||
bool first = true;
|
||||
auto append = [&](std::string_view key, std::string_view value) {
|
||||
if (value.empty()) return;
|
||||
if (!first) url += '&';
|
||||
first = false;
|
||||
url += std::string(key);
|
||||
url += "=eq:";
|
||||
url += rfc3986PercentEncode(value);
|
||||
};
|
||||
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
append("set.id", idCanon);
|
||||
append("localId", num);
|
||||
} else {
|
||||
append("name", name);
|
||||
append("set.id", idCanon);
|
||||
append("localId", num);
|
||||
}
|
||||
const std::string num = normalizeNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
query += " number:";
|
||||
query += num;
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseSetCards(const std::string& body) {
|
||||
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex EN set detail missing 'cards' array."});
|
||||
}
|
||||
std::vector<SetCardRow> out;
|
||||
out.reserve(j.at("cards").size());
|
||||
for (const auto& card : j.at("cards")) {
|
||||
SetCardRow row;
|
||||
row.localId = card.value("localId", "");
|
||||
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
|
||||
}
|
||||
row.name = card.value("name", "");
|
||||
row.rarity = card.value("rarity", "");
|
||||
if (card.contains("image") && card.at("image").is_string()) {
|
||||
row.imageBase = card.at("image").get<std::string>();
|
||||
}
|
||||
if (row.localId.empty()) continue;
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex EN set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
|
||||
if (!j.is_object()) {
|
||||
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex EN card has no image."});
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("images") || !first.at("images").is_object()) {
|
||||
return R::err({K::NotFound, "Card has no 'images' object."});
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex EN card image field is not a string."});
|
||||
}
|
||||
const auto& images = first.at("images");
|
||||
if (images.contains("large") && images.at("large").is_string()) {
|
||||
return R::ok(images.at("large").get<std::string>());
|
||||
const std::string base = j.at("image").get<std::string>();
|
||||
if (base.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex EN card has no image."});
|
||||
}
|
||||
if (images.contains("small") && images.at("small").is_string()) {
|
||||
return R::ok(images.at("small").get<std::string>());
|
||||
}
|
||||
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what()});
|
||||
std::string("TCGdex EN card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "TCGdex EN cards search response is not an array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex EN returned no matching cards."});
|
||||
}
|
||||
for (const auto& card : j) {
|
||||
if (!card.contains("image") || !card.at("image").is_string()) continue;
|
||||
const std::string base = card.at("image").get<std::string>();
|
||||
if (base.empty()) continue;
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex EN matching cards have no image."});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex EN cards search JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +195,221 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
const std::string url = buildSearchUrl(name, setId, setNo);
|
||||
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
|
||||
if (byId) {
|
||||
auto img = parseCardByIdResponse(byId.value());
|
||||
if (img) return img;
|
||||
// NotFound / Transient schema: fall through to search.
|
||||
}
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, idCanon, num);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseResponse(resp.value());
|
||||
return parseSearchResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view /*setId*/,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!wantedLower.empty()) {
|
||||
if (toLower(trim(row.name)) != wantedLower) continue;
|
||||
}
|
||||
const std::string localId = normalizeCollectorNumber(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto list = detectPrintVariants(name, setId);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
if (!idCanon.empty()) {
|
||||
auto detail = http_.get(buildSetDetailUrl(idCanon));
|
||||
if (detail) {
|
||||
auto parsed = parsePrintVariants(detail.value(), idCanon, name);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: filtered cards search by name (+ optional set).
|
||||
const std::string url = buildSearchUrl(name, idCanon, "");
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(resp.value());
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("TCGdex EN returned no matching cards.");
|
||||
}
|
||||
const std::string wantedLower = toLower(trim(std::string(name)));
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedLower) continue;
|
||||
}
|
||||
if (!idCanon.empty()) {
|
||||
std::string cardSetId;
|
||||
if (card.contains("set") && card.at("set").is_object()) {
|
||||
cardSetId = trim(card.at("set").value("id", ""));
|
||||
} else if (card.contains("id") && card.at("id").is_string()) {
|
||||
// Slim search hits are "setId-localId".
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) cardSetId = id.substr(0, dash);
|
||||
}
|
||||
if (cardSetId != idCanon) continue;
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
|
||||
print.rarity = trim(card.value("rarity", ""));
|
||||
if (print.setNo.empty() && print.rarity.empty()) continue;
|
||||
const std::string key = print.setNo + '\0' + print.rarity;
|
||||
if (!seen.insert(key).second) continue;
|
||||
collected.push_back(std::move(print));
|
||||
}
|
||||
if (collected.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::ok(std::move(collected));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::parsePrintFromCardById(
|
||||
const std::string& body) {
|
||||
using R = Result<AutoDetectedPrint>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object()) {
|
||||
return R::err("TCGdex EN card response is not a JSON object.");
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.name = trim(j.value("name", ""));
|
||||
print.setNo = normalizeCollectorNumber(j.value("localId", ""));
|
||||
print.rarity = trim(j.value("rarity", ""));
|
||||
if (print.name.empty()) {
|
||||
return R::err("TCGdex EN card has no name.");
|
||||
}
|
||||
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
|
||||
const std::string id = j.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) {
|
||||
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
|
||||
}
|
||||
}
|
||||
return R::ok(std::move(print));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex EN card JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
if (idCanon.empty()) return R::err("Select a set first.");
|
||||
if (num.empty()) return R::err("Card number is empty.");
|
||||
|
||||
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
|
||||
if (byId) {
|
||||
auto parsed = parsePrintFromCardById(byId.value());
|
||||
if (parsed && localIdsMatch(parsed.value().setNo, num)) {
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.push_back(std::move(parsed).value());
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: filtered search by set.id + localId.
|
||||
const std::string url = buildSearchUrl("", idCanon, num);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(resp.value());
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : j) {
|
||||
AutoDetectedPrint print;
|
||||
print.name = trim(card.value("name", ""));
|
||||
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
|
||||
print.rarity = trim(card.value("rarity", ""));
|
||||
if (print.name.empty()) continue;
|
||||
if (print.setNo.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) {
|
||||
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: TCGdex search can be fuzzy; never accept a
|
||||
// different localId (e.g. "14" when the user asked for "4").
|
||||
if (!localIdsMatch(print.setNo, num)) continue;
|
||||
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
|
||||
if (!seen.insert(key).second) continue;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::unordered_map<std::string, const Set*> indexById(const std::vector<Set>& sets) {
|
||||
std::unordered_map<std::string, const Set*> out;
|
||||
out.reserve(sets.size());
|
||||
for (const auto& s : sets) {
|
||||
if (s.id.empty()) continue;
|
||||
out.emplace(s.id, &s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool applySetMetadata(PokemonCard& card, const Set& upstream) {
|
||||
bool changed = false;
|
||||
if (card.set.name != upstream.name) {
|
||||
card.set.name = upstream.name;
|
||||
changed = true;
|
||||
}
|
||||
if (card.set.releaseDate != upstream.releaseDate) {
|
||||
card.set.releaseDate = upstream.releaseDate;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::size_t syncPokemonCollectionSets(std::vector<PokemonCard>& cards,
|
||||
const std::vector<Set>& westSets,
|
||||
const std::vector<Set>& asiaSets) {
|
||||
const auto westById = indexById(westSets);
|
||||
const auto asiaById = indexById(asiaSets);
|
||||
|
||||
std::size_t touched = 0;
|
||||
for (auto& card : cards) {
|
||||
bool changed = false;
|
||||
if (card.region == PokemonRegion::West) {
|
||||
if (!card.set.id.empty()) {
|
||||
const std::string canon = canonicalizeWestSetId(card.set.id);
|
||||
if (canon != card.set.id) {
|
||||
card.set.id = canon;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!card.set.id.empty()) {
|
||||
if (const auto it = westById.find(card.set.id); it != westById.end()) {
|
||||
if (applySetMetadata(card, *it->second)) changed = true;
|
||||
}
|
||||
}
|
||||
} else if (card.region == PokemonRegion::Asia) {
|
||||
if (!card.set.id.empty()) {
|
||||
if (const auto it = asiaById.find(card.set.id); it != asiaById.end()) {
|
||||
if (applySetMetadata(card, *it->second)) changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) ++touched;
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,45 +1,170 @@
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
|
||||
std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/en/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"Pokemon TCG API response missing 'data' array.");
|
||||
"TCGdex EN sets response is not a JSON array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
|
||||
// no separator rewrite needed (cf. Scryfall's "released_at").
|
||||
s.releaseDate = entry.value("releaseDate", "");
|
||||
s.id = entry.value("id", "");
|
||||
if (s.id.empty()) continue;
|
||||
s.name = entry.value("name", "");
|
||||
s.releaseDate = {}; // filled from set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
std::string("TCGdex EN sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex EN set detail response is not a JSON object.");
|
||||
}
|
||||
const std::string raw = j.value("releaseDate", "");
|
||||
if (raw.empty()) {
|
||||
return Result<std::string>::ok(std::string{});
|
||||
}
|
||||
return Result<std::string>::ok(rewriteReleaseDate(raw));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set) {
|
||||
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
|
||||
if (!rows) {
|
||||
return Result<PokemonSetCatalogPack>::err(rows.error().message);
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& row : rows.value()) {
|
||||
const std::string localId =
|
||||
PokemonCardPreviewSource::normalizeCollectorNumber(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
std::string name = row.name;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return parsed;
|
||||
|
||||
std::vector<Set> out = std::move(parsed).value();
|
||||
for (auto& s : out) {
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue; // keep set with empty date rather than fail all
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
|
||||
|
||||
std::vector<Set> sets = std::move(parsed).value();
|
||||
PokemonSetCatalog catalog;
|
||||
catalog.packs.reserve(sets.size());
|
||||
|
||||
for (auto& s : sets) {
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue;
|
||||
|
||||
if (s.releaseDate.empty()) {
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
auto pack = parseCatalogPackFromSetDetail(detail.value(), s);
|
||||
if (pack) {
|
||||
catalog.packs.push_back(std::move(pack).value());
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against
|
||||
// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids
|
||||
// (base1, swsh1, sv10, sve, …) pass through unchanged.
|
||||
const std::unordered_map<std::string, std::string>& legacyAliases() {
|
||||
static const std::unordered_map<std::string, std::string> kMap{
|
||||
// Classic / EX / HGSS renames
|
||||
{"base6", "lc"},
|
||||
{"bp", "bog"},
|
||||
{"tk1a", "tk-ex-latia"},
|
||||
{"tk1b", "tk-ex-latio"},
|
||||
{"tk2a", "tk-ex-p"},
|
||||
{"tk2b", "tk-ex-m"},
|
||||
{"hsp", "hgssp"},
|
||||
|
||||
// McDonald's Collections
|
||||
{"mcd11", "2011bw"},
|
||||
{"mcd12", "2012bw"},
|
||||
{"mcd14", "2014xy"},
|
||||
{"mcd15", "2015xy"},
|
||||
{"mcd16", "2016xy"},
|
||||
{"mcd17", "2017sm"},
|
||||
{"mcd18", "2018sm"},
|
||||
{"mcd19", "2019sm"},
|
||||
{"mcd21", "2021swsh"},
|
||||
{"mcd22", "2022swsh"},
|
||||
{"mcd23", "2023sv"},
|
||||
{"mcd24", "2024sv"},
|
||||
|
||||
// SM specials
|
||||
{"sm35", "sm3.5"},
|
||||
{"sm75", "sm7.5"},
|
||||
|
||||
// SWSH specials / galleries
|
||||
{"swsh35", "swsh3.5"},
|
||||
{"swsh45", "swsh4.5"},
|
||||
{"swsh45sv", "swsh4.5sv"},
|
||||
{"cel25c", "cel25cc"},
|
||||
{"swsh9tg", "swsh9.5tg"},
|
||||
{"swsh10tg", "swsh10.5tg"},
|
||||
{"pgo", "swsh10.5"},
|
||||
{"swsh11tg", "swsh11.5tg"},
|
||||
{"swsh12tg", "swsh12.5tg"},
|
||||
{"swsh12pt5", "swsh12.5"},
|
||||
{"swsh12pt5gg", "swsh12.5gg"},
|
||||
|
||||
// Scarlet & Violet (pokemontcg used unpadded / pt5 forms)
|
||||
{"sv1", "sv01"},
|
||||
{"sv2", "sv02"},
|
||||
{"sv3", "sv03"},
|
||||
{"sv3pt5", "sv03.5"},
|
||||
{"sv4", "sv04"},
|
||||
{"sv4pt5", "sv04.5"},
|
||||
{"sv5", "sv05"},
|
||||
{"sv6", "sv06"},
|
||||
{"sv6pt5", "sv06.5"},
|
||||
{"sv7", "sv07"},
|
||||
{"sv8", "sv08"},
|
||||
{"sv8pt5", "sv08.5"},
|
||||
{"sv9", "sv09"},
|
||||
{"zsv10pt5", "sv10.5b"},
|
||||
{"rsv10pt5", "sv10.5w"},
|
||||
|
||||
// Mega Evolution era
|
||||
{"me1", "me01"},
|
||||
{"me2", "me02"},
|
||||
{"me2pt5", "me02.5"},
|
||||
{"me3", "me03"},
|
||||
{"me4", "me04"},
|
||||
{"me5", "me05"},
|
||||
};
|
||||
return kMap;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string canonicalizeWestSetId(std::string_view setId) {
|
||||
if (setId.empty()) return {};
|
||||
const auto& map = legacyAliases();
|
||||
const auto it = map.find(std::string(setId));
|
||||
if (it != map.end()) return it->second;
|
||||
return std::string(setId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,551 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string_view s) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return std::string(s.substr(i));
|
||||
}
|
||||
|
||||
bool localIdsMatch(std::string_view a, std::string_view b) {
|
||||
if (a == b) return true;
|
||||
return stripLeadingZeros(a) == stripLeadingZeros(b);
|
||||
}
|
||||
|
||||
bool catalogPrintMatchesRow(const JapanesePokemonPrintEnInfo& print,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row) {
|
||||
// Reject stale catalog rows whose Japanese name disagrees with TCGdex.
|
||||
// Seed data historically mapped Charmander→001 / Charizard→004; those
|
||||
// localIds are Bulbasaur / Weedle on PMCG1.
|
||||
if (print.nameJa.empty()) return true;
|
||||
return asciiLower(print.nameJa) == asciiLower(row.nameJa);
|
||||
}
|
||||
|
||||
bool nameMatchesRow(std::string_view wantedLower,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row,
|
||||
std::string_view setId,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
if (wantedLower.empty()) return true;
|
||||
if (asciiLower(row.nameJa) == wantedLower) return true;
|
||||
if (auto print = catalog.findPrint(setId, row.localId)) {
|
||||
if (!catalogPrintMatchesRow(*print, row)) return false;
|
||||
if (asciiLower(print->nameEn) == wantedLower) return true;
|
||||
if (asciiLower(print->nameJa) == wantedLower) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonCardPreviewSource::JapanesePokemonCardPreviewSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::normalizeLocalId(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) s.erase(slash);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildCardUrl(std::string_view setId,
|
||||
std::string_view localId) {
|
||||
std::string id = std::string(setId) + "-" + std::string(localId);
|
||||
return std::string("https://api.tcgdex.net/v2/ja/cards/") +
|
||||
rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
|
||||
if (imageBase.empty()) return {};
|
||||
std::string url(imageBase);
|
||||
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
|
||||
return url + "/high.png";
|
||||
}
|
||||
|
||||
Result<std::vector<JapanesePokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseSetCards(const std::string& body) {
|
||||
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex JA set detail missing 'cards' array."});
|
||||
}
|
||||
std::vector<SetCardRow> out;
|
||||
out.reserve(j.at("cards").size());
|
||||
for (const auto& card : j.at("cards")) {
|
||||
SetCardRow row;
|
||||
row.localId = card.value("localId", "");
|
||||
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
// Fallback: take suffix after last '-' from card id.
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
|
||||
}
|
||||
row.nameJa = card.value("name", "");
|
||||
row.rarity = card.value("rarity", "");
|
||||
if (card.contains("image") && card.at("image").is_string()) {
|
||||
row.imageBase = card.at("image").get<std::string>();
|
||||
}
|
||||
if (row.localId.empty()) continue;
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseCardImageUrl(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object()) {
|
||||
return R::err({K::Transient, "TCGdex JA card response is not a JSON object."});
|
||||
}
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex JA card image field is not a string."});
|
||||
}
|
||||
const std::string base = j.at("image").get<std::string>();
|
||||
if (base.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenCatalogUrls;
|
||||
|
||||
// Prefer catalog EN matches first so typed English names resolve — but
|
||||
// only when the catalog localId exists in the set and name_ja agrees
|
||||
// with TCGdex (guards against stale seed mappings).
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
const SetCardRow* row = nullptr;
|
||||
for (const auto& r : rows.value()) {
|
||||
if (localIdsMatch(r.localId, p.localId)) {
|
||||
row = &r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (row == nullptr) {
|
||||
// Set detail sometimes omits cards[]; keep catalog-only hits
|
||||
// (UnnumberedPromo). Dedupe only among non-empty preview URLs
|
||||
// so empty-image prints still appear in the Next ring.
|
||||
if (!rows.value().empty()) continue;
|
||||
if (!previewUrl.empty() &&
|
||||
!seenCatalogUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
} else if (!catalogPrintMatchesRow(p, *row)) {
|
||||
continue;
|
||||
}
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (row != nullptr) print.rarity = row->rarity;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!nameMatchesRow(wantedLower, row, setId, catalog)) continue;
|
||||
if (!seen.insert(row.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = row.localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty() && !wantedLower.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (!catalog.hasPrintsForSet(setId)) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenUrls;
|
||||
if (wantedLower.empty()) {
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
// Dedupe only among non-empty preview URLs so identical art is not
|
||||
// cycled; empty-image prints still join the Next ring (card-back).
|
||||
// Emit imaged prints first so Auto-detect lands on real art.
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (!previewUrl.empty() && !seenUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
if (out.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string localId = normalizeLocalId(setNo);
|
||||
if (setId.empty()) {
|
||||
return R::err({K::NotFound, "Japanese Pokemon preview requires a set id."});
|
||||
}
|
||||
|
||||
auto catalogPreviewFor = [&](std::string_view lid) -> Result<std::string, PreviewLookupError> {
|
||||
if (lid.empty()) return R::err({K::NotFound, "No catalog preview for print."});
|
||||
if (auto print = catalog_.findPrint(setId, lid)) {
|
||||
const std::string catalogUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(*print);
|
||||
if (!catalogUrl.empty()) return R::ok(catalogUrl);
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
};
|
||||
|
||||
// Prefer direct card fetch when we have a localId.
|
||||
if (!localId.empty()) {
|
||||
auto cardResp = http_.get(buildCardUrl(setId, localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
// NotFound from card object: fall through to set list / catalog.
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Synthetic / classic products: try catalog gap-fill before set detail.
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
// Known catalog print with no preview URL: honest miss (do not
|
||||
// borrow a sibling print's art via name match).
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
// Catalog-only products (City Gym theme decks, etc.) are not on TCGdex.
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
// Network failure and no catalog entry: Transient so a brief outage
|
||||
// is not negative-cached as a permanent miss.
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
auto rows = parseSetCards(setResp.value());
|
||||
if (!rows) return R::err(rows.error());
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
const SetCardRow* chosen = nullptr;
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!localId.empty() && localIdsMatch(row.localId, localId)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Name match only when setNo was not provided — never borrow a sibling
|
||||
// print's art for a concrete localId.
|
||||
if (chosen == nullptr && localId.empty() && !wantedLower.empty()) {
|
||||
for (const auto& row : rows.value()) {
|
||||
if (nameMatchesRow(wantedLower, row, setId, catalog_)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
// Empty cards[] with catalog prints: resolve from catalog.
|
||||
if (rows.value().empty() && catalog_.hasPrintsForSet(setId)) {
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!chosen->imageBase.empty()) {
|
||||
return R::ok(imageUrlFromBase(chosen->imageBase));
|
||||
}
|
||||
|
||||
// Try full card object — set résumé sometimes omits image.
|
||||
auto cardResp = http_.get(buildCardUrl(setId, chosen->localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Odd localId padding can 404; still try catalog gap-fill below.
|
||||
}
|
||||
|
||||
// Classic JA sets often have image:null on TCGdex. Prefer a catalog
|
||||
// printing-accurate TCGPlayer product image for this exact setId+localId
|
||||
// (never search other printings by Pokémon name).
|
||||
return catalogPreviewFor(chosen->localId);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint>
|
||||
JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto variants = detectPrintVariants(name, setId);
|
||||
if (!variants) return Result<AutoDetectedPrint>::err(variants.error());
|
||||
if (variants.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(variants.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) {
|
||||
if (setId.empty()) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Select a set before auto-detecting Japanese Pokemon prints.");
|
||||
}
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(setResp.error());
|
||||
}
|
||||
auto parsed = parsePrintVariants(setResp.value(), setId, name, catalog_);
|
||||
if (parsed) return parsed;
|
||||
// Empty/unusable TCGdex detail: fall back to catalog prints when present.
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::parsePrintFromCardResponse(
|
||||
const std::string& body) {
|
||||
using R = Result<AutoDetectedPrint>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object()) {
|
||||
return R::err("TCGdex JA card response is not a JSON object.");
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.name = trim(j.value("name", ""));
|
||||
print.setNo = normalizeLocalId(j.value("localId", ""));
|
||||
print.rarity = trim(j.value("rarity", ""));
|
||||
if (print.name.empty()) {
|
||||
return R::err("TCGdex JA card has no name.");
|
||||
}
|
||||
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
|
||||
const std::string id = j.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) {
|
||||
print.setNo = normalizeLocalId(id.substr(dash + 1));
|
||||
}
|
||||
}
|
||||
return R::ok(std::move(print));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("TCGdex JA card JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
|
||||
std::string_view setId,
|
||||
std::string_view localId,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string id = normalizeLocalId(localId);
|
||||
if (setId.empty()) return R::err("Select a set first.");
|
||||
if (id.empty()) return R::err("Card number is empty.");
|
||||
|
||||
// Prefer exact key, then leading-zero-insensitive scan ("1" ↔ "001").
|
||||
auto found = catalog.findPrint(setId, id);
|
||||
if (!found) {
|
||||
for (const auto& print : catalog.printsForSet(setId)) {
|
||||
if (localIdsMatch(print.localId, id)) {
|
||||
found = print;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.name = !found->nameEn.empty() ? found->nameEn : found->nameJa;
|
||||
print.setNo = found->localId.empty() ? id : found->localId;
|
||||
if (print.name.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.push_back(std::move(print));
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::detectBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (setId.empty()) return R::err("Select a set first.");
|
||||
const std::string id = normalizeLocalId(setNo);
|
||||
if (id.empty()) return R::err("Card number is empty.");
|
||||
|
||||
auto cardResp = http_.get(buildCardUrl(setId, id));
|
||||
if (cardResp) {
|
||||
auto parsed = parsePrintFromCardResponse(cardResp.value());
|
||||
if (parsed && localIdsMatch(parsed.value().setNo, id)) {
|
||||
// Prefer EN catalog name when available (exact or zero-insensitive).
|
||||
if (auto cat = catalog_.findPrint(setId, id); cat && !cat->nameEn.empty()) {
|
||||
parsed.value().name = cat->nameEn;
|
||||
} else {
|
||||
for (const auto& p : catalog_.printsForSet(setId)) {
|
||||
if (localIdsMatch(p.localId, id) && !p.nameEn.empty()) {
|
||||
parsed.value().name = p.nameEn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.push_back(std::move(parsed).value());
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectVariantsBySetNoFromCatalog(setId, id, catalog_);
|
||||
}
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string printKey(std::string_view setId, std::string_view localId) {
|
||||
return std::string(setId) + '\0' + std::string(localId);
|
||||
}
|
||||
|
||||
bool isAsciiAlnumToken(std::string_view s) {
|
||||
if (s.empty()) return false;
|
||||
for (unsigned char ch : s) {
|
||||
if (!std::isalnum(ch)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True when `needle` appears in `hay` as a whole alphanumeric token
|
||||
/// (e.g. "mewtwo" in "team gr's mewtwo" / "mewtwo strikes back", but not
|
||||
/// "mew" inside "mewtwo"). ASCII needles only.
|
||||
bool containsWholeAsciiToken(std::string_view hay, std::string_view needle) {
|
||||
if (!isAsciiAlnumToken(needle)) return false;
|
||||
const std::size_t n = needle.size();
|
||||
for (std::size_t i = 0; i + n <= hay.size(); ++i) {
|
||||
if (hay.compare(i, n, needle) != 0) continue;
|
||||
const bool leftOk = i == 0 || !std::isalnum(static_cast<unsigned char>(hay[i - 1]));
|
||||
const bool rightOk =
|
||||
i + n == hay.size() ||
|
||||
!std::isalnum(static_cast<unsigned char>(hay[i + n]));
|
||||
if (leftOk && rightOk) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Result<JapanesePokemonEnCatalog>
|
||||
JapanesePokemonEnCatalog::parse(const std::string& jsonBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(jsonBody);
|
||||
JapanesePokemonEnCatalog out;
|
||||
|
||||
if (j.contains("sets") && j.at("sets").is_object()) {
|
||||
for (auto it = j.at("sets").begin(); it != j.at("sets").end(); ++it) {
|
||||
JapanesePokemonSetEnInfo info;
|
||||
info.nameEn = it.value().value("name_en", "");
|
||||
info.nameJa = it.value().value("name_ja", "");
|
||||
info.releaseDate = it.value().value("releaseDate", "");
|
||||
out.sets_[it.key()] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (j.contains("prints") && j.at("prints").is_array()) {
|
||||
for (const auto& entry : j.at("prints")) {
|
||||
JapanesePokemonPrintEnInfo info;
|
||||
info.setId = entry.value("set_id", "");
|
||||
info.localId = entry.value("local_id", "");
|
||||
info.nameEn = entry.value("name_en", "");
|
||||
info.nameJa = entry.value("name_ja", "");
|
||||
info.nameEnSource = entry.value("name_en_source", "");
|
||||
info.imageUrl = entry.value("image_url", "");
|
||||
if (entry.contains("tcgplayer_id")) {
|
||||
const auto& tp = entry.at("tcgplayer_id");
|
||||
if (tp.is_string()) {
|
||||
info.tcgplayerId = tp.get<std::string>();
|
||||
} else if (tp.is_number_integer()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::int64_t>());
|
||||
} else if (tp.is_number_unsigned()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::uint64_t>());
|
||||
}
|
||||
}
|
||||
if (info.setId.empty() || info.localId.empty()) continue;
|
||||
const std::string key = printKey(info.setId, info.localId);
|
||||
out.printKeysBySet_[info.setId].push_back(key);
|
||||
out.printsByKey_[key] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
return Result<JapanesePokemonEnCatalog>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<JapanesePokemonEnCatalog>::err(
|
||||
std::string("Japanese Pokemon EN catalog JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonSetEnInfo>
|
||||
JapanesePokemonEnCatalog::findSet(std::string_view setId) const {
|
||||
const auto it = sets_.find(std::string(setId));
|
||||
if (it == sets_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrint(std::string_view setId,
|
||||
std::string_view localId) const {
|
||||
const auto it = printsByKey_.find(printKey(setId, localId));
|
||||
if (it == printsByKey_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrintsByName(std::string_view setId,
|
||||
std::string_view cardName) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
if (cardName.empty()) return out;
|
||||
const std::string wanted = asciiLower(std::string(cardName));
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
const auto& p = pit->second;
|
||||
const std::string enLower = asciiLower(p.nameEn);
|
||||
const std::string jaLower = asciiLower(p.nameJa);
|
||||
// Exact, qualified "Mewtwo (...)", or whole-token in a longer title
|
||||
// ("Team GR's Mewtwo", "Mewtwo Strikes Back (...)").
|
||||
if (enLower == wanted || jaLower == wanted ||
|
||||
enLower.starts_with(wanted + " (") ||
|
||||
containsWholeAsciiToken(enLower, wanted) ||
|
||||
containsWholeAsciiToken(jaLower, wanted)) {
|
||||
out.push_back(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noexcept {
|
||||
const auto it = printKeysBySet_.find(std::string(setId));
|
||||
return it != printKeysBySet_.end() && !it->second.empty();
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
out.reserve(keysIt->second.size());
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
out.push_back(pit->second);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
|
||||
if (productId.empty()) return {};
|
||||
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
|
||||
std::string(productId) + ".jpg";
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print) {
|
||||
if (!print.imageUrl.empty()) return print.imageUrl;
|
||||
return tcgplayerImageUrl(print.tcgplayerId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
JapanesePokemonGameModule::JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog)
|
||||
: catalog_(std::move(catalog)),
|
||||
setSource_(http, catalog_),
|
||||
previewSource_(http, catalog_) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,353 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
struct ClassicMissingProduct {
|
||||
const char* id;
|
||||
const char* nameEn;
|
||||
const char* releaseDate; // YYYY/MM/DD
|
||||
};
|
||||
|
||||
// Keep in sync with tools/pokemon_jp/classic_missing_sets.json and
|
||||
// docs/assets-and-info-apis.md (Japanese Pokémon Info API).
|
||||
constexpr std::array<ClassicMissingProduct, 11> kMissingClassicProducts{{
|
||||
// Day after Pokémon Jungle (PMCG2, 1997/03/05) so the set list places
|
||||
// Unnumbered Promo immediately after Jungle when sorted by releaseDate.
|
||||
{"UnnumberedPromo", "Unnumbered Promotional cards", "1997/03/06"},
|
||||
{"ExpSheet1", "Expansion Sheet Series 1", "1998/03/23"},
|
||||
{"NiviCG", "Nivi City Gym", "1998/04/26"},
|
||||
{"HanadaCG", "Hanada City Gym", "1998/04/26"},
|
||||
{"ExpSheet2", "Expansion Sheet Series 2", "1998/06/17"},
|
||||
{"KuchibaCG", "Kuchiba City Gym", "1998/07/25"},
|
||||
{"TamamushiCG", "Tamamushi City Gym", "1998/07/25"},
|
||||
{"ExpSheet3", "Expansion Sheet Series 3", "1998/11/24"},
|
||||
{"YamabukiCG", "Yamabuki City Gym", "1999/02/26"},
|
||||
{"GurenTG", "Guren Town Gym", "1999/02/26"},
|
||||
{"SouthernIslands", "Southern Islands", "1999/07/17"},
|
||||
}};
|
||||
|
||||
const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
|
||||
// Field-level corrections for known TCGdex JA mislabels (never edit cache).
|
||||
static const std::unordered_map<std::string, std::string> kOverrides{
|
||||
{"SV4a", "シャイニートレジャーex"},
|
||||
};
|
||||
return kOverrides;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool containsCjk(std::string_view s) noexcept {
|
||||
// Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE3–0xE9).
|
||||
// Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK —
|
||||
// that used to wipe catalog English names back to the set id.
|
||||
for (unsigned char ch : s) {
|
||||
if (ch >= 0xE3 && ch <= 0xE9) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
|
||||
const JapanesePokemonEnCatalog& enCatalog) {
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : pack.cards) {
|
||||
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
|
||||
}
|
||||
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
std::string name = print.nameEn;
|
||||
if (name.empty()) name = print.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
}
|
||||
|
||||
void sortPackCards(PokemonSetCatalogPack& pack) {
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
}
|
||||
|
||||
void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) {
|
||||
if (auto en = catalog.findSet(s.id)) {
|
||||
if (!en->nameEn.empty()) s.name = en->nameEn;
|
||||
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
|
||||
}
|
||||
if (s.name.empty() || containsCjk(s.name)) {
|
||||
s.name = s.id;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonSetSource::JapanesePokemonSetSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
bool JapanesePokemonSetSource::shouldExcludeSetId(std::string_view setId) noexcept {
|
||||
// Chinese-region CS* entries are mislabeled on the JA endpoint.
|
||||
return setId.size() >= 2 && setId[0] == 'C' && setId[1] == 'S';
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa) {
|
||||
const auto& overrides = setNameJaOverrides();
|
||||
const auto it = overrides.find(std::string(setId));
|
||||
if (it != overrides.end()) return it->second;
|
||||
return nameJa;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>>
|
||||
JapanesePokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"TCGdex JA sets response is not a JSON array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
if (s.id.empty() || shouldExcludeSetId(s.id)) continue;
|
||||
s.name = applySetNameOverride(s.id, entry.value("name", ""));
|
||||
s.releaseDate = {}; // filled from catalog or set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
appendMissingClassicProducts(out);
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("TCGdex JA sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::appendMissingClassicProducts(std::vector<Set>& sets) {
|
||||
for (const auto& product : kMissingClassicProducts) {
|
||||
auto it = std::find_if(sets.begin(), sets.end(), [&](const Set& s) {
|
||||
return s.id == product.id;
|
||||
});
|
||||
if (it != sets.end()) {
|
||||
// Keep curated display name / sort date in sync (e.g. UnnumberedPromo
|
||||
// placement after Pokémon Jungle) even when the id was already cached.
|
||||
it->name = product.nameEn;
|
||||
it->releaseDate = product.releaseDate;
|
||||
continue;
|
||||
}
|
||||
Set s;
|
||||
s.id = product.id;
|
||||
s.name = product.nameEn;
|
||||
s.releaseDate = product.releaseDate;
|
||||
sets.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string>
|
||||
JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex JA set detail response is not a JSON object.");
|
||||
}
|
||||
const std::string raw = j.value("releaseDate", "");
|
||||
if (raw.empty()) {
|
||||
return Result<std::string>::ok(std::string{});
|
||||
}
|
||||
return Result<std::string>::ok(rewriteReleaseDate(raw));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("TCGdex JA set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog(
|
||||
const Set& set, const JapanesePokemonEnCatalog& enCatalog) {
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
for (const auto& print : enCatalog.printsForSet(set.id)) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
|
||||
if (localId.empty()) continue;
|
||||
std::string name = print.nameEn;
|
||||
if (name.empty()) name = print.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
sortPackCards(pack);
|
||||
return pack;
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set,
|
||||
const JapanesePokemonEnCatalog& enCatalog) {
|
||||
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
|
||||
if (!rows) {
|
||||
// Transient/NotFound from parse — treat empty cards as catalog-only.
|
||||
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
|
||||
auto pack = catalogPackFromEnCatalog(set, enCatalog);
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err(
|
||||
"No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::err(rows.error().message);
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& row : rows.value()) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
|
||||
std::string name;
|
||||
if (auto print = enCatalog.findPrint(set.id, localId)) {
|
||||
name = print->nameEn;
|
||||
if (name.empty()) name = print->nameJa;
|
||||
}
|
||||
if (name.empty()) name = row.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
|
||||
gapFillFromEnCatalog(pack, enCatalog);
|
||||
sortPackCards(pack);
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return parsed;
|
||||
|
||||
std::vector<Set> out = std::move(parsed).value();
|
||||
for (auto& s : out) {
|
||||
// Prefer catalog English; never leave Japanese TCGdex names in Set.name
|
||||
// (the set picker must stay English-only).
|
||||
applyEnglishSetName(s, catalog_);
|
||||
if (!s.releaseDate.empty()) continue;
|
||||
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue; // keep set with empty date rather than fail all
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<JapanesePokemonSetSource::FetchWithCatalog>
|
||||
JapanesePokemonSetSource::fetchAllWithCatalog() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
|
||||
|
||||
std::vector<Set> sets = std::move(parsed).value();
|
||||
PokemonSetCatalog catalog;
|
||||
catalog.packs.reserve(sets.size());
|
||||
|
||||
for (auto& s : sets) {
|
||||
applyEnglishSetName(s, catalog_);
|
||||
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) {
|
||||
// Classic / catalog-only products often have no TCGdex detail.
|
||||
auto pack = catalogPackFromEnCatalog(s, catalog_);
|
||||
if (!pack.cards.empty()) {
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.releaseDate.empty()) {
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
|
||||
if (pack) {
|
||||
catalog.packs.push_back(std::move(pack).value());
|
||||
} else {
|
||||
auto fallback = catalogPackFromEnCatalog(s, catalog_);
|
||||
if (!fallback.cards.empty()) {
|
||||
catalog.packs.push_back(std::move(fallback));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
|
||||
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
|
||||
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
|
||||
for (auto& s : sets) {
|
||||
applyEnglishSetName(s, catalog_);
|
||||
}
|
||||
appendMissingClassicProducts(sets);
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,10 +1,12 @@
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
@@ -16,31 +18,6 @@ namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
|
||||
// here so the YGO and Magic code paths can drift independently if the future
|
||||
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
|
||||
// for the `|` separator inside `titles=`).
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
@@ -54,6 +31,17 @@ std::string toLower(std::string s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
|
||||
std::string canonical = trim(std::string(setName));
|
||||
constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
|
||||
if (canonical.size() > k25thSuffix.size()
|
||||
&& canonical.ends_with(k25thSuffix)) {
|
||||
canonical.erase(canonical.size() - k25thSuffix.size());
|
||||
canonical = trim(std::move(canonical));
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
|
||||
// always return card_images[0]: when no `cardset=` filter is applied, that
|
||||
// slot is the original/standard artwork (alt-art passcodes follow), which is
|
||||
@@ -134,6 +122,10 @@ std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
|
||||
}
|
||||
|
||||
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
|
||||
if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Compare case-insensitively, ignoring whitespace, against a table of
|
||||
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
|
||||
// plus a few extras occasionally seen in imported collections. The codes
|
||||
@@ -171,7 +163,7 @@ std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName)
|
||||
{"ultraparallelrare", "UPR"},
|
||||
{"holographicrare", "HGR"},
|
||||
{"starlightrare", "StR"},
|
||||
{"collectorsrare", "ColR"},
|
||||
{"collectorsrare", "CR"},
|
||||
{"prismaticcollectorsrare", "PColR"},
|
||||
{"quartercenturysecretrare", "QCScR"},
|
||||
{"prismaticultimaterare", "PUtR"},
|
||||
@@ -266,7 +258,7 @@ std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
|
||||
std::string url =
|
||||
"https://yugipedia.com/api.php?action=query&format=json"
|
||||
"&prop=imageinfo&iiprop=url&titles=";
|
||||
url += urlEncode(joined);
|
||||
url += rfc3986PercentEncode(joined);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -330,10 +322,10 @@ Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaR
|
||||
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName) {
|
||||
std::string url =
|
||||
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
|
||||
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
|
||||
if (!setName.empty()) {
|
||||
url += "&cardset=";
|
||||
url += urlEncode(setName);
|
||||
url += rfc3986PercentEncode(setName);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
@@ -385,7 +377,7 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVarian
|
||||
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
|
||||
return R::err("YGOPRODeck returned no matching cards.");
|
||||
}
|
||||
const std::string wantedSet = trim(std::string(preferredSetName));
|
||||
const std::string wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
@@ -551,15 +543,188 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setId);
|
||||
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
|
||||
const std::string url = buildSearchUrl(name, canonicalSetName);
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setId, name);
|
||||
return parsePrintVariants(resp.value(), canonicalSetName, name);
|
||||
}
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setId, name);
|
||||
return parsePrintVariants(fallback.value(), canonicalSetName, name);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string packId = std::string(trimAsciiSpaces(setId));
|
||||
if (packId.empty()) return R::err("Select a set first.");
|
||||
|
||||
const std::string rawNo = std::string(trimAsciiSpaces(setNo));
|
||||
if (rawNo.empty()) return R::err("Card number is empty.");
|
||||
|
||||
const std::string wantDigits =
|
||||
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo));
|
||||
if (wantDigits.empty()) return R::err("Card number is empty.");
|
||||
|
||||
const YuGiOhSetCatalogPack* pack = catalog.findPack(packId);
|
||||
if (pack == nullptr) {
|
||||
// Allow callers to pass the display set name (HTTP fallback path).
|
||||
for (const auto& candidate : catalog.packs) {
|
||||
if (candidate.setName == packId) {
|
||||
pack = &candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pack == nullptr) {
|
||||
return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seenNames;
|
||||
for (const auto& card : pack->cards) {
|
||||
if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue;
|
||||
if (card.name.empty()) continue;
|
||||
if (!seenNames.insert(card.name).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.name = card.name;
|
||||
print.setNo = card.setNo;
|
||||
print.rarity = card.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) {
|
||||
return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") +
|
||||
rfc3986PercentEncode(setName);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
|
||||
const std::string& body,
|
||||
std::string_view preferredSetName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string wantDigits =
|
||||
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo));
|
||||
if (wantDigits.empty()) return R::err("Card number is empty.");
|
||||
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return R::err("YGOPRODeck response missing 'data' array.");
|
||||
}
|
||||
const std::string preferredLower = toLower(trim(std::string(preferredSetName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : j.at("data")) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (cardName.empty()) continue;
|
||||
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
|
||||
for (const auto& printing : card.at("card_sets")) {
|
||||
const std::string setName = trim(printing.value("set_name", ""));
|
||||
const std::string setCode = trim(printing.value("set_code", ""));
|
||||
if (setCode.empty()) continue;
|
||||
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
|
||||
if (!preferredLower.empty() && toLower(setName) != preferredLower) continue;
|
||||
if (!ygoCollectorDigitsEqual(setCode, setNo)) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.name = cardName;
|
||||
print.setNo = setCode;
|
||||
print.rarity = trim(printing.value("set_rarity", ""));
|
||||
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
|
||||
if (!seen.insert(key).second) continue;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect card name from set number.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect card name from set number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectVariantsBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string setKey = std::string(trimAsciiSpaces(setId));
|
||||
if (setKey.empty()) return R::err("Select a set first.");
|
||||
if (ygoCollectorDigitsFromInput(setNo).empty()) {
|
||||
return R::err("Card number is empty.");
|
||||
}
|
||||
|
||||
// 1) Offline catalog (preferred — fast once cached).
|
||||
if (catalogStore_ != nullptr) {
|
||||
if (!catalogCache_) {
|
||||
auto loaded = catalogStore_->load();
|
||||
if (loaded) catalogCache_ = std::move(loaded).value();
|
||||
}
|
||||
if (catalogCache_ && !catalogCache_->empty()) {
|
||||
auto fromCatalog =
|
||||
detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo);
|
||||
|
||||
// Prefer YGOPRODeck when reachable so rarity (and multi-rarity
|
||||
// variants) come through — the offline catalog may predate the
|
||||
// rarity field or only keep one rarity per printing slot.
|
||||
const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey);
|
||||
std::string setName = setKey;
|
||||
if (pack != nullptr) {
|
||||
setName = pack->setName;
|
||||
} else {
|
||||
for (const auto& candidate : catalogCache_->packs) {
|
||||
if (candidate.setName == setKey) {
|
||||
setName = candidate.setName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!setName.empty()) {
|
||||
auto resp = http_.get(buildCardsetOnlyUrl(setName));
|
||||
if (resp) {
|
||||
auto fromHttp =
|
||||
detectVariantsBySetNoFromCardset(resp.value(), setName, setNo);
|
||||
if (fromHttp) return fromHttp;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromCatalog) return fromCatalog;
|
||||
// Prefer catalog miss text when HTTP also missed / was unreachable.
|
||||
return fromCatalog;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) No catalog: treat setKey as display set name and query YGOPRODeck.
|
||||
auto resp = http_.get(buildCardsetOnlyUrl(setKey));
|
||||
if (!resp) {
|
||||
return R::err(
|
||||
"Set catalog missing and YGOPRODeck lookup failed. "
|
||||
"Run Sets → Update Yu-Gi-Oh! or check your network.");
|
||||
}
|
||||
return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,11 +1,91 @@
|
||||
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
|
||||
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
struct YuGiOhSetAlias {
|
||||
const char* code;
|
||||
const char* name;
|
||||
const char* releaseDate;
|
||||
};
|
||||
|
||||
constexpr std::array<YuGiOhSetAlias, 6> kMissing25thAnniversaryReprints{{
|
||||
// Keep this list in sync with docs/assets-and-info-apis.md (Info API section).
|
||||
{"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"PSV-25TH", "Pharaoh's Servant (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"DCR-25TH", "Dark Crisis (25th Anniversary Edition)", "2023/04/20"},
|
||||
{"IOC-25TH", "Invasion of Chaos (25th Anniversary Edition)", "2023/06/08"},
|
||||
}};
|
||||
|
||||
void appendMissingSetAliases(std::vector<Set>& sets) {
|
||||
for (const auto& alias : kMissing25thAnniversaryReprints) {
|
||||
const bool exists = std::any_of(
|
||||
sets.begin(), sets.end(), [&](const Set& s) { return s.name == alias.name; });
|
||||
if (exists) continue;
|
||||
|
||||
Set s;
|
||||
s.id = alias.code;
|
||||
s.name = alias.name;
|
||||
s.releaseDate = alias.releaseDate;
|
||||
sets.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
|
||||
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
|
||||
const std::string digits = ygoCollectorDigitsOnly(setNo);
|
||||
if (abbrev.empty() || digits.empty()) return {};
|
||||
return abbrev + "|" + digits;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) {
|
||||
const std::string_view s = trimAsciiSpaces(setCode);
|
||||
const auto dash = s.find('-');
|
||||
if (dash == std::string_view::npos || dash + 3 > s.size()) return false;
|
||||
const std::string_view tail = s.substr(dash + 1);
|
||||
if (tail.size() < 3) return false;
|
||||
return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n')
|
||||
&& std::isdigit(static_cast<unsigned char>(tail[2])) != 0;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string uppercaseAscii(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::toupper(c));
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string resolvePackId(const std::unordered_map<std::string, std::string>& nameToId,
|
||||
const std::string& setName,
|
||||
const std::string& setCode) {
|
||||
const auto it = nameToId.find(setName);
|
||||
if (it != nameToId.end() && !it->second.empty()) return it->second;
|
||||
const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode));
|
||||
return abbrev;
|
||||
}
|
||||
|
||||
struct PackBuild {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
// slotKey → index into cards (for EN preference upgrades).
|
||||
std::unordered_map<std::string, std::size_t> slotIndex;
|
||||
std::vector<YuGiOhCatalogCard> cards;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
@@ -29,6 +109,7 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
|
||||
s.releaseDate = std::move(release);
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
appendMissingSetAliases(out);
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
@@ -38,10 +119,124 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
|
||||
}
|
||||
}
|
||||
|
||||
Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& body,
|
||||
const std::vector<Set>& sets) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<YuGiOhSetCatalog>::err(
|
||||
"YGOPRODeck cardinfo response missing data array.");
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::string> nameToId;
|
||||
nameToId.reserve(sets.size());
|
||||
for (const auto& set : sets) {
|
||||
if (set.name.empty() || set.id.empty()) continue;
|
||||
// First wins — aliases and upstream rows rarely collide by name.
|
||||
nameToId.emplace(set.name, set.id);
|
||||
}
|
||||
|
||||
// Keyed by pack setId.
|
||||
std::unordered_map<std::string, PackBuild> byId;
|
||||
|
||||
for (const auto& cardJson : j.at("data")) {
|
||||
const std::string cardName = cardJson.value("name", "");
|
||||
if (cardName.empty()) continue;
|
||||
if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) {
|
||||
continue;
|
||||
}
|
||||
for (const auto& printing : cardJson.at("card_sets")) {
|
||||
const std::string setName = printing.value("set_name", "");
|
||||
const std::string setCode = printing.value("set_code", "");
|
||||
if (setName.empty() || setCode.empty()) continue;
|
||||
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
|
||||
|
||||
const std::string slot = ygoSlotKey(setCode);
|
||||
if (slot.empty()) continue;
|
||||
|
||||
const std::string packId = resolvePackId(nameToId, setName, setCode);
|
||||
if (packId.empty()) continue;
|
||||
|
||||
auto& build = byId[packId];
|
||||
if (build.setId.empty()) {
|
||||
build.setId = packId;
|
||||
build.setName = setName;
|
||||
}
|
||||
|
||||
const auto existing = build.slotIndex.find(slot);
|
||||
if (existing == build.slotIndex.end()) {
|
||||
build.slotIndex.emplace(slot, build.cards.size());
|
||||
const std::string setRarity(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer an EN-embedded code over a bare / other-region equivalent.
|
||||
auto& prev = build.cards[existing->second];
|
||||
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
|
||||
prev.setNo = setCode;
|
||||
if (!cardName.empty()) prev.name = cardName;
|
||||
const std::string setRarity(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
if (!setRarity.empty()) prev.rarity = setRarity;
|
||||
} else if (prev.rarity.empty()) {
|
||||
prev.rarity = std::string(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
YuGiOhSetCatalog catalog;
|
||||
catalog.packs.reserve(byId.size());
|
||||
for (auto& [_, build] : byId) {
|
||||
if (build.setId.empty() || build.cards.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
YuGiOhSetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<YuGiOhSetCatalog>::err(
|
||||
std::string("YGOPRODeck catalog parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::fetchAllWithCatalog() {
|
||||
auto setsResp = http_.get(kEndpoint);
|
||||
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
|
||||
|
||||
auto sets = parseResponse(setsResp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
|
||||
auto infoResp = http_.get(kCardInfoEndpoint);
|
||||
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
|
||||
|
||||
auto catalog = parseCatalog(infoResp.value(), sets.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
std::string trimCopy(std::string_view s) {
|
||||
while (!s.empty() &&
|
||||
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
|
||||
s.front() == '\r')) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() &&
|
||||
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
|
||||
s.back() == '\r')) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return std::string(s);
|
||||
}
|
||||
|
||||
std::string urlEncode(std::string_view s) {
|
||||
static constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size() * 3);
|
||||
for (unsigned char c : s) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else if (c == ' ') {
|
||||
out.push_back('+');
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(hex[c >> 4]);
|
||||
out.push_back(hex[c & 0xF]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string wikiTitleEncode(std::string_view title) {
|
||||
// MediaWiki titles use underscores for spaces in the titles= parameter.
|
||||
std::string s;
|
||||
s.reserve(title.size());
|
||||
for (char c : title) {
|
||||
s.push_back(c == ' ' ? '_' : c);
|
||||
}
|
||||
return urlEncode(s);
|
||||
}
|
||||
|
||||
bool endsWith(std::string_view s, std::string_view suffix) {
|
||||
return s.size() >= suffix.size() &&
|
||||
s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
|
||||
}
|
||||
|
||||
int askMatchRank(std::string_view pageTitle, std::string_view preferredSetId) {
|
||||
// Lower is better.
|
||||
if (preferredSetId == "bansealdass") {
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 0;
|
||||
if (endsWith(pageTitle, " (Bandai)")) return 1;
|
||||
return 5;
|
||||
}
|
||||
if (preferredSetId == "ban3") {
|
||||
if (endsWith(pageTitle, " (Bandai)")) return 0;
|
||||
if (endsWith(pageTitle, " (English Bandai)")) return 1;
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 4;
|
||||
return 5;
|
||||
}
|
||||
if (endsWith(pageTitle, " (Bandai)")) return 0;
|
||||
if (endsWith(pageTitle, " (English Bandai)")) return 1;
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 3;
|
||||
return 5;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhBandaiCardPreviewSource::YuGiOhBandaiCardPreviewSource(IHttpClient& http)
|
||||
: http_(http) {}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::preferredPageTitle(
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string n = trimCopy(name);
|
||||
if (n.empty()) return {};
|
||||
|
||||
const std::string num = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
if (setId == "bansealdass") {
|
||||
return n + " (Bandai Sealdass)";
|
||||
}
|
||||
// Promo pages on Yugipedia often omit the "(Bandai)" disambiguator
|
||||
// (e.g. Blue-Eyes White Dragon's 3-Body Connection for TA2).
|
||||
if (setId == "banpromo-j" || setId == "banpromo-ta" ||
|
||||
isAlphanumericPromoNumber(num)) {
|
||||
return n;
|
||||
}
|
||||
if (num == "118" || setId == "ban3") {
|
||||
// Prefer JP Bandai page for most ban3 cards; English #118 uses the
|
||||
// English Bandai title when setNo is 118.
|
||||
if (num == "118") return n + " (English Bandai)";
|
||||
}
|
||||
return n + " (Bandai)";
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::buildPageImagesUrl(
|
||||
std::string_view pageTitle) {
|
||||
return std::string(
|
||||
"https://yugipedia.com/api.php?action=query&format=json"
|
||||
"&prop=pageimages&piprop=original&titles=") +
|
||||
wikiTitleEncode(pageTitle);
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::buildAskByNameUrl(
|
||||
std::string_view englishName) {
|
||||
// [[Category:Bandai cards]][[English name::<name>]]|?English name|?Bandai number|?Rarity|limit=20
|
||||
std::ostringstream q;
|
||||
q << "[[Category:Bandai cards]][[English name::" << englishName
|
||||
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
|
||||
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
|
||||
urlEncode(q.str());
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiCardPreviewSource::buildAskByNumberUrl(
|
||||
std::string_view setNo) {
|
||||
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
std::ostringstream q;
|
||||
q << "[[Category:Bandai cards]][[Bandai number::" << n
|
||||
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
|
||||
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
|
||||
urlEncode(q.str());
|
||||
}
|
||||
|
||||
bool YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber(
|
||||
std::string_view setNo) {
|
||||
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
for (unsigned char c : n) {
|
||||
if (std::isalpha(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::parsePromoGalleryResponse(
|
||||
const std::string& body,
|
||||
std::string_view wantedSetNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string want = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
|
||||
if (want.empty()) return R::err("Card number is empty.");
|
||||
|
||||
std::string wikitext;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
|
||||
return R::err("Yugipedia promo gallery response missing parse.wikitext");
|
||||
}
|
||||
wikitext = j.at("parse").at("wikitext").get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Yugipedia promo gallery JSON parse error: ") +
|
||||
e.what());
|
||||
}
|
||||
|
||||
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wikitext);
|
||||
if (!cards) return R::err(cards.error());
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
for (const auto& card : cards.value()) {
|
||||
if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo) != want) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.name = card.name;
|
||||
print.setNo = card.setNo;
|
||||
print.rarity = card.rarity;
|
||||
print.setId = YuGiOhBandaiSetSource::setIdForNumber(card.setNo);
|
||||
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
|
||||
print.language = "Japanese";
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
AutoDetectedPrint YuGiOhBandaiCardPreviewSource::enrichPrint(
|
||||
AutoDetectedPrint print,
|
||||
std::string_view pageTitle) {
|
||||
print.name = YuGiOhBandaiSetSource::englishNameFromGalleryTitle(pageTitle);
|
||||
|
||||
if (endsWith(pageTitle, " (Bandai Sealdass)")) {
|
||||
print.setId = "bansealdass";
|
||||
print.language = "Japanese";
|
||||
} else if (endsWith(pageTitle, " (English Bandai)")) {
|
||||
print.setId = "ban3";
|
||||
print.language = "English";
|
||||
} else {
|
||||
if (print.setId.empty() && !print.setNo.empty()) {
|
||||
print.setId = YuGiOhBandaiSetSource::setIdForNumber(print.setNo);
|
||||
}
|
||||
print.language = "Japanese";
|
||||
}
|
||||
if (!print.setId.empty()) {
|
||||
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
|
||||
}
|
||||
return print;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("query") || !j.at("query").contains("pages")) {
|
||||
return R::err({K::Transient, "Yugipedia pageimages: missing query.pages"});
|
||||
}
|
||||
const auto& pages = j.at("query").at("pages");
|
||||
for (auto it = pages.begin(); it != pages.end(); ++it) {
|
||||
const auto& page = it.value();
|
||||
if (page.contains("missing") || page.contains("invalid")) continue;
|
||||
if (page.contains("original") && page.at("original").contains("source")) {
|
||||
const auto url = page.at("original").at("source").get<std::string>();
|
||||
if (!url.empty()) return R::ok(url);
|
||||
}
|
||||
if (page.contains("thumbnail") && page.at("thumbnail").contains("original")) {
|
||||
const auto url = page.at("thumbnail").at("original").get<std::string>();
|
||||
if (!url.empty()) return R::ok(url);
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "Yugipedia pageimages: no image for page"});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("Yugipedia pageimages JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body,
|
||||
std::string_view preferredSetId,
|
||||
std::string_view wantedSetNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("query") || !j.at("query").contains("results")) {
|
||||
return R::err("Yugipedia ask: missing query.results");
|
||||
}
|
||||
const auto& results = j.at("query").at("results");
|
||||
if (!results.is_object() || results.empty()) {
|
||||
return R::ok({});
|
||||
}
|
||||
|
||||
const std::string wantNo = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
|
||||
|
||||
std::vector<std::pair<int, AutoDetectedPrint>> ranked;
|
||||
for (auto it = results.begin(); it != results.end(); ++it) {
|
||||
const std::string pageTitle = it.key();
|
||||
const auto& printouts = it.value().value("printouts", nlohmann::json::object());
|
||||
|
||||
AutoDetectedPrint print;
|
||||
if (printouts.contains("Bandai number") &&
|
||||
printouts.at("Bandai number").is_array() &&
|
||||
!printouts.at("Bandai number").empty()) {
|
||||
const auto& num = printouts.at("Bandai number").at(0);
|
||||
if (num.is_number_integer()) {
|
||||
print.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(
|
||||
std::to_string(num.get<int>()));
|
||||
} else if (num.is_string()) {
|
||||
print.setNo =
|
||||
YuGiOhBandaiSetSource::normalizeCardNumber(num.get<std::string>());
|
||||
}
|
||||
}
|
||||
// Defense-in-depth: SMW ask should be exact, but never accept a
|
||||
// different Bandai number (e.g. #11 when the user asked for #1).
|
||||
if (!wantNo.empty() &&
|
||||
YuGiOhBandaiSetSource::normalizeCardNumber(print.setNo) != wantNo) {
|
||||
continue;
|
||||
}
|
||||
if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() &&
|
||||
!printouts.at("Rarity").empty()) {
|
||||
const auto& rar = printouts.at("Rarity").at(0);
|
||||
if (rar.is_object() && rar.contains("fulltext")) {
|
||||
print.rarity = rar.at("fulltext").get<std::string>();
|
||||
} else if (rar.is_string()) {
|
||||
print.rarity = rar.get<std::string>();
|
||||
}
|
||||
}
|
||||
if (printouts.contains("English name") &&
|
||||
printouts.at("English name").is_array() &&
|
||||
!printouts.at("English name").empty()) {
|
||||
print.name = printouts.at("English name").at(0).get<std::string>();
|
||||
}
|
||||
|
||||
print = enrichPrint(std::move(print), pageTitle);
|
||||
if (print.name.empty()) continue;
|
||||
ranked.emplace_back(askMatchRank(pageTitle, preferredSetId), std::move(print));
|
||||
}
|
||||
|
||||
std::sort(ranked.begin(), ranked.end(),
|
||||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
out.reserve(ranked.size());
|
||||
for (auto& [rank, print] : ranked) {
|
||||
(void)rank;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Yugipedia ask JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhBandaiCardPreviewSource::fetchPageImage(std::string_view pageTitle) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
if (pageTitle.empty()) {
|
||||
return R::err({K::NotFound, "Empty Bandai page title"});
|
||||
}
|
||||
const std::string url = buildPageImagesUrl(pageTitle);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parsePageImagesResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByName(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string n = trimCopy(name);
|
||||
if (n.empty()) return R::err("Card name is empty.");
|
||||
const std::string url = buildAskByNameUrl(n);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
return parseAskResponse(resp.value(), setId);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByNumber(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
|
||||
if (n.empty()) return R::err("Card number is empty.");
|
||||
|
||||
// Promo codes (J1, TA2, …) are not valid values for SMW's numeric
|
||||
// `Bandai number` property — ask returns a type error. Resolve them from
|
||||
// the promotional set gallery instead.
|
||||
R list = [&]() -> R {
|
||||
if (isAlphanumericPromoNumber(n)) {
|
||||
static constexpr const char* kPromoGallery =
|
||||
"Set Card Galleries:Promotional Cards (Bandai)";
|
||||
const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
return parsePromoGalleryResponse(resp.value(), n);
|
||||
}
|
||||
const std::string url = buildAskByNumberUrl(n);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
return parseAskResponse(resp.value(), setId, n);
|
||||
}();
|
||||
if (!list) return list;
|
||||
|
||||
const std::string wantSet = trimCopy(setId);
|
||||
if (wantSet.empty()) return list;
|
||||
|
||||
std::vector<AutoDetectedPrint> filtered;
|
||||
filtered.reserve(list.value().size());
|
||||
for (auto& print : list.value()) {
|
||||
if (print.setId == wantSet) filtered.push_back(std::move(print));
|
||||
}
|
||||
if (filtered.empty()) {
|
||||
return R::err("No Bandai card matched that number in the selected set.");
|
||||
}
|
||||
return R::ok(std::move(filtered));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto list = detectVariantsBySetNo(setId, setNo);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"Could not auto-detect Bandai card from number.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
return askByNumber(setId, setNo);
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhBandaiCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
|
||||
const std::string title = preferredPageTitle(name, setId, setNo);
|
||||
auto direct = fetchPageImage(title);
|
||||
if (direct) return direct;
|
||||
// Try English Bandai if JP page missed for #118.
|
||||
if (YuGiOhBandaiSetSource::normalizeCardNumber(setNo) == "118") {
|
||||
auto en = fetchPageImage(trimCopy(name) + " (English Bandai)");
|
||||
if (en) return en;
|
||||
}
|
||||
|
||||
// Fall back to SMW ask by name, then pageimages on the best hit.
|
||||
auto variants = askByName(name, setId);
|
||||
if (!variants) {
|
||||
// Prefer the original NotFound if ask also failed transiently only
|
||||
// after a clean miss; otherwise surface ask error as Transient.
|
||||
if (direct.error().kind == K::NotFound) {
|
||||
return R::err({K::Transient, variants.error()});
|
||||
}
|
||||
return direct;
|
||||
}
|
||||
if (variants.value().empty()) {
|
||||
return R::err({K::NotFound, "No Bandai card matched the name"});
|
||||
}
|
||||
|
||||
const auto& best = variants.value().front();
|
||||
std::string askTitle = preferredPageTitle(best.name, best.setId, best.setNo);
|
||||
if (best.language == "English") {
|
||||
askTitle = best.name + " (English Bandai)";
|
||||
} else if (best.setId == "bansealdass") {
|
||||
askTitle = best.name + " (Bandai Sealdass)";
|
||||
}
|
||||
return fetchPageImage(askTitle);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectFirstPrint(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto list = detectPrintVariants(name, setId);
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
if (list.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect Bandai print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) {
|
||||
return askByName(name, setId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
YuGiOhBandaiGameModule::YuGiOhBandaiGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,267 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <regex>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trimCopy(std::string_view s) {
|
||||
while (!s.empty() &&
|
||||
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
|
||||
s.front() == '\r')) {
|
||||
s.remove_prefix(1);
|
||||
}
|
||||
while (!s.empty() &&
|
||||
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
|
||||
s.back() == '\r')) {
|
||||
s.remove_suffix(1);
|
||||
}
|
||||
return std::string(s);
|
||||
}
|
||||
|
||||
std::string urlEncode(std::string_view s) {
|
||||
static constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string out;
|
||||
out.reserve(s.size() * 3);
|
||||
for (unsigned char c : s) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
out.push_back(static_cast<char>(c));
|
||||
} else if (c == ' ') {
|
||||
out.push_back('+');
|
||||
} else {
|
||||
out.push_back('%');
|
||||
out.push_back(hex[c >> 4]);
|
||||
out.push_back(hex[c & 0xF]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhBandaiSetSource::YuGiOhBandaiSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
const std::vector<YuGiOhBandaiSetSource::SetManifestEntry>&
|
||||
YuGiOhBandaiSetSource::setManifest() {
|
||||
static const std::vector<SetManifestEntry> kManifest{
|
||||
{"ban1", "1st Generation", "1998/09/01",
|
||||
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation", ""},
|
||||
{"ban2", "2nd Generation", "1998/11/01",
|
||||
"Set Card Galleries:2nd Generation (Bandai)", ""},
|
||||
{"ban3", "3rd Generation", "1999/03/06",
|
||||
"Set Card Galleries:3rd Generation (Bandai)", ""},
|
||||
{"banpromo-j", "Jump Promos", "1998/01/01",
|
||||
"Set Card Galleries:Promotional Cards (Bandai)", "J"},
|
||||
{"banpromo-ta", "Toei Promos", "1999/03/06",
|
||||
"Set Card Galleries:Promotional Cards (Bandai)", "TA"},
|
||||
{"bansealdass", "Sealdass", "1999/06/01",
|
||||
"Set Card Galleries:Yu-Gi-Oh! Bandai Sealdass", ""},
|
||||
};
|
||||
return kManifest;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhBandaiSetSource::parseResponse(
|
||||
const std::string& /*unused*/) {
|
||||
std::vector<Set> out;
|
||||
for (const auto& e : setManifest()) {
|
||||
out.push_back(Set{e.id, e.name, e.releaseDate});
|
||||
}
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhBandaiSetSource::fetchAll() {
|
||||
return parseResponse({});
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::buildGalleryParseUrl(std::string_view pageTitle) {
|
||||
return std::string(
|
||||
"https://yugipedia.com/api.php?action=parse&format=json&formatversion=2"
|
||||
"&prop=wikitext&page=") +
|
||||
urlEncode(pageTitle);
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::normalizeCardNumber(std::string_view setNo) {
|
||||
std::string s = trimCopy(setNo);
|
||||
if (s.empty()) return {};
|
||||
|
||||
// Strip a leading '#' if present.
|
||||
if (s.front() == '#') s.erase(s.begin());
|
||||
|
||||
// Uppercase letter prefix forms: j1 / ta2.
|
||||
bool hasAlpha = false;
|
||||
for (char& c : s) {
|
||||
if (std::isalpha(static_cast<unsigned char>(c))) {
|
||||
hasAlpha = true;
|
||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
}
|
||||
if (hasAlpha) return s;
|
||||
|
||||
// Pure decimal: strip leading zeros but keep a single zero.
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return s.substr(i);
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::expandRarityCode(std::string_view code) {
|
||||
const std::string c = trimCopy(code);
|
||||
if (c == "C") return "Common";
|
||||
if (c == "R") return "Rare";
|
||||
if (c == "SR") return "Super Rare";
|
||||
if (c == "UR") return "Ultra Rare";
|
||||
if (c == "HFR" || c == "Holo Seal" || c == "HS") return "Holo Seal";
|
||||
if (c.empty()) return {};
|
||||
return c;
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::englishNameFromGalleryTitle(
|
||||
std::string_view pageTitle) {
|
||||
std::string name = trimCopy(pageTitle);
|
||||
const auto stripSuffix = [&](std::string_view suffix) {
|
||||
if (name.size() > suffix.size() &&
|
||||
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||
name.resize(name.size() - suffix.size());
|
||||
name = trimCopy(name);
|
||||
}
|
||||
};
|
||||
stripSuffix(" (Bandai Sealdass)");
|
||||
stripSuffix(" (English Bandai)");
|
||||
stripSuffix(" (Bandai)");
|
||||
return name;
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::setIdForNumber(std::string_view setNo) {
|
||||
const std::string n = normalizeCardNumber(setNo);
|
||||
if (n.empty()) return {};
|
||||
if (!n.empty() && (n[0] == 'J' || n[0] == 'j')) return "banpromo-j";
|
||||
if (n.size() >= 2 && (n[0] == 'T' || n[0] == 't') &&
|
||||
(n[1] == 'A' || n[1] == 'a')) {
|
||||
return "banpromo-ta";
|
||||
}
|
||||
|
||||
// Pure decimal → generation by range. Callers that need Sealdass must
|
||||
// pass set context; number alone cannot disambiguate 1–42 vs Sealdass.
|
||||
bool pureDecimal = true;
|
||||
for (char c : n) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(c))) {
|
||||
pureDecimal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pureDecimal) return {};
|
||||
|
||||
const int v = std::stoi(n);
|
||||
if (v >= 1 && v <= 42) return "ban1";
|
||||
if (v >= 43 && v <= 88) return "ban2";
|
||||
if (v >= 89 && v <= 118) return "ban3";
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string YuGiOhBandaiSetSource::setNameForId(std::string_view setId) {
|
||||
for (const auto& e : setManifest()) {
|
||||
if (e.id == setId) return e.name;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Result<std::vector<YuGiOhBandaiCatalogCard>>
|
||||
YuGiOhBandaiSetSource::parseGalleryWikitext(const std::string& wikitext) {
|
||||
using R = Result<std::vector<YuGiOhBandaiCatalogCard>>;
|
||||
std::vector<YuGiOhBandaiCatalogCard> out;
|
||||
|
||||
// Generation galleries (raw):
|
||||
// … | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|ja}}
|
||||
// Promo galleries (often expanded with <br />):
|
||||
// … | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}
|
||||
static const std::regex kLine(
|
||||
R"((?:\{\{pound\}\}|\[\[)([A-Za-z0-9]+)(?:\]\])?(?:\s*\(\[\[([A-Za-z0-9]+)\]\]\))?[^\n]*?\{\{Gallery card names\|([^}|]+))",
|
||||
std::regex::ECMAScript);
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (std::sregex_iterator it(wikitext.begin(), wikitext.end(), kLine), end;
|
||||
it != end; ++it) {
|
||||
const std::smatch& m = *it;
|
||||
YuGiOhBandaiCatalogCard card;
|
||||
card.setNo = normalizeCardNumber(m[1].str());
|
||||
if (card.setNo.empty()) continue;
|
||||
if (m[2].matched) {
|
||||
card.rarity = expandRarityCode(m[2].str());
|
||||
}
|
||||
card.name = englishNameFromGalleryTitle(m[3].str());
|
||||
if (card.name.empty()) continue;
|
||||
if (!seen.insert(card.setNo).second) continue;
|
||||
out.push_back(std::move(card));
|
||||
}
|
||||
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<YuGiOhBandaiSetSource::FetchWithCatalog>
|
||||
YuGiOhBandaiSetSource::fetchAllWithCatalog() {
|
||||
using R = Result<FetchWithCatalog>;
|
||||
|
||||
auto sets = parseResponse({});
|
||||
if (!sets) return R::err(sets.error());
|
||||
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
std::unordered_map<std::string, std::string> pageCache;
|
||||
|
||||
for (const auto& entry : setManifest()) {
|
||||
const std::string page = entry.galleryPage;
|
||||
std::string body;
|
||||
auto cached = pageCache.find(page);
|
||||
if (cached != pageCache.end()) {
|
||||
body = cached->second;
|
||||
} else {
|
||||
const std::string url = buildGalleryParseUrl(page);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err(resp.error());
|
||||
body = std::move(resp).value();
|
||||
pageCache.emplace(page, body);
|
||||
}
|
||||
|
||||
std::string wikitext;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
|
||||
return R::err("Yugipedia gallery response missing parse.wikitext");
|
||||
}
|
||||
wikitext = j.at("parse").at("wikitext").get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Yugipedia gallery JSON parse error: ") +
|
||||
e.what());
|
||||
}
|
||||
|
||||
auto cards = parseGalleryWikitext(wikitext);
|
||||
if (!cards) return R::err(cards.error());
|
||||
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = entry.id;
|
||||
pack.setName = entry.name;
|
||||
const std::string prefix = entry.setNoPrefix;
|
||||
for (const auto& card : cards.value()) {
|
||||
if (!prefix.empty()) {
|
||||
if (card.setNo.size() < prefix.size() ||
|
||||
card.setNo.compare(0, prefix.size(), prefix) != 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
pack.cards.push_back(card);
|
||||
}
|
||||
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog);
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
|
||||
#include "ccm/util/HttpGetMapping.hpp"
|
||||
|
||||
#include <cpr/cpr.h>
|
||||
|
||||
#include <string>
|
||||
@@ -24,8 +26,30 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
|
||||
/*follow=*/true,
|
||||
/*cont_send_cred=*/false,
|
||||
cpr::PostRedirectFlags::POST_ALL});
|
||||
rawExecutor_ = [this](std::string_view url) -> RawResponse {
|
||||
session_->SetUrl(cpr::Url{std::string(url)});
|
||||
cpr::Response r = session_->Get();
|
||||
return RawResponse{
|
||||
.transportError = static_cast<bool>(r.error),
|
||||
.transportMessage = r.error.message,
|
||||
.statusCode = static_cast<int>(r.status_code),
|
||||
.body = std::move(r.text),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
CprHttpClient::CprHttpClient(GetExecutor executor,
|
||||
std::chrono::milliseconds timeout)
|
||||
: timeout_(timeout),
|
||||
session_(nullptr),
|
||||
executor_(std::move(executor)) {}
|
||||
|
||||
CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor,
|
||||
std::chrono::milliseconds timeout)
|
||||
: timeout_(timeout),
|
||||
session_(nullptr),
|
||||
rawExecutor_(std::move(rawExecutor)) {}
|
||||
|
||||
CprHttpClient::~CprHttpClient() = default;
|
||||
|
||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
@@ -34,18 +58,18 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
// (one fetch per BaseSelectedCardPanel selection change), so contention
|
||||
// is negligible.
|
||||
std::lock_guard<std::mutex> lock(sessionMutex_);
|
||||
|
||||
session_->SetUrl(cpr::Url{std::string(url)});
|
||||
cpr::Response r = session_->Get();
|
||||
|
||||
if (r.error) {
|
||||
return Result<std::string>::err("HTTP error: " + r.error.message);
|
||||
if (executor_) {
|
||||
return executor_(url);
|
||||
}
|
||||
if (r.status_code < 200 || r.status_code >= 300) {
|
||||
return Result<std::string>::err(
|
||||
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
|
||||
if (rawExecutor_) {
|
||||
RawResponse raw = rawExecutor_(url);
|
||||
return mapHttpGetResponse(raw.transportError,
|
||||
raw.transportMessage,
|
||||
raw.statusCode,
|
||||
std::move(raw.body),
|
||||
url);
|
||||
}
|
||||
return Result<std::string>::ok(std::move(r.text));
|
||||
return Result<std::string>::err("HTTP error: no executor configured");
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -12,24 +12,59 @@ JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, Dir
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path JsonSetRepository::setsPath(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
|
||||
const fs::path root = fs::path(config_.current().dataStorage) / dirName_(game);
|
||||
switch (game) {
|
||||
case Game::Pokemon: return root / "sets-west.json";
|
||||
case Game::JapanesePokemon: return root / "sets-asia.json";
|
||||
default: return root / "sets.json";
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
fs::path JsonSetRepository::legacySetsPath(Game game) const {
|
||||
const fs::path dataRoot(config_.current().dataStorage);
|
||||
switch (game) {
|
||||
case Game::Pokemon:
|
||||
// Pre-flatten: pokemon/sets.json
|
||||
return dataRoot / "pokemon" / "sets.json";
|
||||
case Game::JapanesePokemon:
|
||||
// Pre-flatten: pokemonjp/sets.json
|
||||
return dataRoot / "pokemonjp" / "sets.json";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::parseSetsText(const std::string& text) const {
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
auto j = nlohmann::json::parse(text);
|
||||
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (fs_.exists(p)) {
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
return parseSetsText(text.value());
|
||||
}
|
||||
|
||||
const auto legacy = legacySetsPath(game);
|
||||
if (!legacy.empty() && fs_.exists(legacy)) {
|
||||
auto text = fs_.readText(legacy);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
auto parsed = parseSetsText(text.value());
|
||||
if (!parsed) return parsed;
|
||||
// Best-effort promote to the new path; UI still gets the sets if write fails.
|
||||
(void)save(game, parsed.value());
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
}
|
||||
|
||||
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
|
||||
const auto p = setsPath(game);
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/AsciiUtils.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
|
||||
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
|
||||
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
|
||||
// identically.
|
||||
std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool containsLower(std::string_view haystack, std::string_view needleLower) {
|
||||
return asciiLower(haystack).find(needleLower) != std::string::npos;
|
||||
}
|
||||
@@ -60,6 +47,7 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
if (containsLower(to_string(card.region), needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -68,6 +56,38 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(card.rarity, needle)) return true;
|
||||
if (containsLower(ygoRarityShortCode(card.rarity), needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card, std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
@@ -79,4 +99,20 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||
std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -36,11 +37,18 @@ std::string makeUrlKey(std::string_view url) {
|
||||
return k;
|
||||
}
|
||||
|
||||
constexpr std::string_view kAssetScheme = "asset:";
|
||||
|
||||
} // namespace
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache)
|
||||
: http_(http), persistentCache_(persistentCache) {}
|
||||
IPreviewByteCache* persistentCache,
|
||||
IFileSystem* fs,
|
||||
std::filesystem::path assetRoot)
|
||||
: http_(http),
|
||||
persistentCache_(persistentCache),
|
||||
fs_(fs),
|
||||
assetRoot_(std::move(assetRoot)) {}
|
||||
|
||||
void CardPreviewService::registerModule(IGameModule& module) {
|
||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
||||
@@ -122,6 +130,37 @@ Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKe
|
||||
return Result<std::string>::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
|
||||
const std::string& cacheKey,
|
||||
std::string_view assetUrl) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
if (fs_ == nullptr || assetRoot_.empty()) {
|
||||
return R::err({K::Transient, "Asset preview path is not configured."});
|
||||
}
|
||||
if (!assetUrl.starts_with(kAssetScheme)) {
|
||||
return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."});
|
||||
}
|
||||
std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size())));
|
||||
const auto fullPath = assetRoot_ / rel;
|
||||
auto bytes = fs_->readText(fullPath);
|
||||
if (!bytes) {
|
||||
return R::err({K::NotFound,
|
||||
"Bundled preview asset not found: " + fullPath.generic_string()});
|
||||
}
|
||||
std::string payload = std::move(bytes).value();
|
||||
if (payload.empty()) {
|
||||
return R::err({K::NotFound,
|
||||
"Bundled preview asset is empty: " + fullPath.generic_string()});
|
||||
}
|
||||
cacheStore(cacheKey, payload);
|
||||
if (persistentCache_ != nullptr) {
|
||||
persistentCache_->store(cacheKey, payload);
|
||||
}
|
||||
return R::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
@@ -179,6 +218,18 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
if (url.value().starts_with(kAssetScheme)) {
|
||||
auto asset = fetchAssetAndCache(key, url.value());
|
||||
if (!asset) {
|
||||
const auto err = std::move(asset).error();
|
||||
if (err.kind == PreviewLookupError::Kind::NotFound) {
|
||||
cacheStoreNegative(key);
|
||||
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
return Result<std::string>::ok(std::move(asset).value());
|
||||
}
|
||||
return fetchAndCache(key, url.value());
|
||||
}
|
||||
|
||||
@@ -211,6 +262,35 @@ Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
|
||||
return it->second->detectPrintVariants(name, setId);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto it = sources_.find(game);
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
|
||||
}
|
||||
if (!it->second->supportsAutoDetectPrint()) {
|
||||
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
|
||||
}
|
||||
return it->second->detectBySetNo(setId, setNo);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
|
||||
Game game,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
auto it = sources_.find(game);
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"No preview source registered for this game.");
|
||||
}
|
||||
if (!it->second->supportsAutoDetectPrint()) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Auto-detect not enabled for this game.");
|
||||
}
|
||||
return it->second->detectVariantsBySetNo(setId, setNo);
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
|
||||
// The by-URL path is used for fixed per-game card-back fallback images.
|
||||
// A failure there is always transient (the URL itself is constant), so
|
||||
@@ -218,18 +298,12 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
|
||||
// and, if needed, fetch+store.
|
||||
const std::string key = makeUrlKey(url);
|
||||
std::string cached;
|
||||
switch (cacheLookup(key, cached)) {
|
||||
case CacheLookupKind::Hit:
|
||||
return Result<std::string>::ok(std::move(cached));
|
||||
case CacheLookupKind::NegativeHit:
|
||||
// Defensive: nothing in this code path ever stores a negative
|
||||
// entry under a URL key, but if one ever ends up here (cache
|
||||
// file tampering, future code paths) treat it as a miss so the
|
||||
// fallback fetch can still run.
|
||||
break;
|
||||
case CacheLookupKind::Miss:
|
||||
break;
|
||||
const auto mem = cacheLookup(key, cached);
|
||||
if (mem == CacheLookupKind::Hit) {
|
||||
return Result<std::string>::ok(std::move(cached));
|
||||
}
|
||||
// Miss, or a spurious negative under a URL key (never written by normal
|
||||
// code) — both continue to disk / network.
|
||||
if (persistentCache_ != nullptr) {
|
||||
const auto disk = persistentCache_->load(key);
|
||||
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
|
||||
@@ -237,6 +311,11 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
|
||||
return Result<std::string>::ok(disk.payload);
|
||||
}
|
||||
}
|
||||
if (url.starts_with(kAssetScheme)) {
|
||||
auto asset = fetchAssetAndCache(key, url);
|
||||
if (!asset) return Result<std::string>::err(asset.error().message);
|
||||
return Result<std::string>::ok(std::move(asset).value());
|
||||
}
|
||||
return fetchAndCache(key, url);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/AsciiUtils.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
|
||||
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
|
||||
// We use ASCII-only tolower; the original TS app processed the same fields and
|
||||
// never special-cased Unicode either, so this stays byte-compatible for the
|
||||
// realistic data set (English/German/etc. names already lowercase identically).
|
||||
std::string asciiLower(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out.push_back(static_cast<char>(
|
||||
std::tolower(static_cast<unsigned char>(c))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wrap a less-than predicate so that ascending=false flips its meaning,
|
||||
// mirroring `byField(field, asc)` in TableTemplate.tsx.
|
||||
template <typename Less>
|
||||
@@ -203,6 +190,12 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Rarity:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return asciiLower(ygoRarityShortCode(a.rarity)) < asciiLower(ygoRarityShortCode(b.rarity));
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
@@ -230,4 +223,217 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
}
|
||||
}
|
||||
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case DigiBattle99SortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
|
||||
YuGiOhBandaiSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case YuGiOhBandaiSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::SetNo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(a.setNo) < asciiLower(b.setNo);
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Rarity:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(a.rarity) < asciiLower(b.rarity);
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhBandaiSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case JapanesePokemonSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs,
|
||||
ConfigService& config,
|
||||
DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path DigiBattle99SetCatalogService::catalogPath() const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) /
|
||||
"set-catalog.json";
|
||||
}
|
||||
|
||||
bool DigiBattle99SetCatalogService::exists() const {
|
||||
return fs_.exists(catalogPath());
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetCatalogService::load() const {
|
||||
const auto p = catalogPath();
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<DigiBattle99SetCatalog>::err(
|
||||
"Digimon Digi-Battle set catalog not yet downloaded.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<DigiBattle99SetCatalog>::err(text.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(text.value());
|
||||
return Result<DigiBattle99SetCatalog>::ok(j.get<DigiBattle99SetCatalog>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<DigiBattle99SetCatalog>::err(
|
||||
std::string("set-catalog.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) {
|
||||
const auto p = catalogPath();
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = catalog;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,129 @@
|
||||
#include "ccm/services/DigiBattle99SetCompletion.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||
|
||||
bool passesLanguageFilter(const DigiBattle99Card& card,
|
||||
std::optional<Language> languageFilter) {
|
||||
return !languageFilter.has_value() || card.language == *languageFilter;
|
||||
}
|
||||
|
||||
OwnedBySet ownedSetNosBySetId(const std::vector<DigiBattle99Card>& collection,
|
||||
std::optional<Language> languageFilter) {
|
||||
OwnedBySet out;
|
||||
for (const auto& card : collection) {
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id.empty()) continue;
|
||||
const std::string setNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (setNo.empty()) continue;
|
||||
out[card.set.id].insert(setNo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Language>
|
||||
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection) {
|
||||
const auto& langs = allLanguages();
|
||||
std::array<bool, 10> present{};
|
||||
for (const auto& card : collection) {
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (langs[i] == card.language) {
|
||||
present[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Language> out;
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (present[i]) out.push_back(langs[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::optional<Language> languageFilter) {
|
||||
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
|
||||
|
||||
std::vector<DigiBattle99SetCompletionProgress> out;
|
||||
out.reserve(owned.size());
|
||||
|
||||
for (const auto& [setId, ownedNos] : owned) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr || pack->cards.empty()) continue;
|
||||
|
||||
std::size_t matched = 0;
|
||||
for (const auto& card : pack->cards) {
|
||||
const std::string catalogNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
|
||||
}
|
||||
|
||||
DigiBattle99SetCompletionProgress row;
|
||||
row.setId = pack->setId;
|
||||
row.setName = pack->setName;
|
||||
row.ownedUnique = matched;
|
||||
row.total = pack->cards.size();
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const DigiBattle99SetCompletionProgress& a,
|
||||
const DigiBattle99SetCompletionProgress& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr) return {};
|
||||
|
||||
std::unordered_set<std::string> ownedNos;
|
||||
for (const auto& card : collection) {
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id != setId) continue;
|
||||
const std::string setNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (!setNo.empty()) ownedNos.insert(setNo);
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99ChecklistEntry> out;
|
||||
out.reserve(pack->cards.size());
|
||||
for (const auto& card : pack->cards) {
|
||||
DigiBattle99ChecklistEntry entry;
|
||||
entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
entry.name = card.name;
|
||||
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const DigiBattle99ChecklistEntry& a,
|
||||
const DigiBattle99ChecklistEntry& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user